Skip to content
Open
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
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ dependencies = [
"objectstore-client>=0.0.5",
"openai>=1.3.5",
"orjson>=3.10.10",
"p4python>=2025.1.2767466",
"packaging>=24.1",
"parsimonious>=0.10.0",
"petname>=2.6",
Expand Down Expand Up @@ -295,6 +296,7 @@ module = [
"onelogin.saml2.auth.*",
"onelogin.saml2.constants.*",
"onelogin.saml2.idp_metadata_parser.*",
"P4",
"rb.*",
"statsd.*",
"tokenizers.*",
Expand Down
1 change: 1 addition & 0 deletions src/sentry/conf/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2148,6 +2148,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
"sentry.integrations.discord.DiscordIntegrationProvider",
"sentry.integrations.opsgenie.OpsgenieIntegrationProvider",
"sentry.integrations.cursor.integration.CursorAgentIntegrationProvider",
"sentry.integrations.perforce.integration.PerforceIntegrationProvider",
)


Expand Down
1 change: 1 addition & 0 deletions src/sentry/integrations/api/bases/external_actor.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
ExternalProviders.SLACK,
ExternalProviders.MSTEAMS,
ExternalProviders.JIRA_SERVER,
ExternalProviders.PERFORCE,
ExternalProviders.CUSTOM,
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ class RepositoryProjectPathConfigSerializer(CamelSnakeModelSerializer):
source_root = gen_path_regex_field()
default_branch = serializers.RegexField(
r"^(^(?![\/]))([\w\.\/-]+)(?<![\/])$",
required=True,
required=False, # Validated in validate_default_branch based on integration type
allow_blank=True, # Perforce allows empty streams
error_messages={"invalid": _(BRANCH_NAME_ERROR_MESSAGE)},
)
instance: RepositoryProjectPathConfig | None
Expand Down Expand Up @@ -98,6 +99,24 @@ def validate_project_id(self, project_id):
raise serializers.ValidationError("Project does not exist")
return project_id

def validate_default_branch(self, default_branch):
# Get the integration to check if it's Perforce
integration = integration_service.get_integration(
integration_id=self.org_integration.integration_id
)

# For Perforce, allow empty branch (streams are part of depot path)
if integration and integration.provider == "perforce":
# Allow empty string for Perforce
if not default_branch:
return default_branch
else:
# For other integrations, branch is required
if not default_branch:
raise serializers.ValidationError("This field is required.")

return default_branch

def create(self, validated_data):
return RepositoryProjectPathConfig.objects.create(
organization_integration_id=self.org_integration.id,
Expand Down
1 change: 1 addition & 0 deletions src/sentry/integrations/models/external_actor.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class ExternalActor(ReplicatedRegionModel):
(ExternalProviders.GITHUB_ENTERPRISE, IntegrationProviderSlug.GITHUB_ENTERPRISE.value),
(ExternalProviders.GITLAB, IntegrationProviderSlug.GITLAB.value),
(ExternalProviders.JIRA_SERVER, IntegrationProviderSlug.JIRA_SERVER.value),
(ExternalProviders.PERFORCE, IntegrationProviderSlug.PERFORCE.value),
# TODO: do migration to delete this from database
(ExternalProviders.CUSTOM, "custom_scm"),
),
Expand Down
6 changes: 6 additions & 0 deletions src/sentry/integrations/perforce/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from __future__ import annotations

from .integration import PerforceIntegration, PerforceIntegrationProvider
from .repository import PerforceRepositoryProvider

__all__ = ["PerforceIntegration", "PerforceIntegrationProvider", "PerforceRepositoryProvider"]
196 changes: 196 additions & 0 deletions src/sentry/integrations/perforce/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
from __future__ import annotations

import logging
from collections.abc import Sequence
from typing import Any

from sentry.integrations.source_code_management.commit_context import (
CommitContextClient,
FileBlameInfo,
SourceLineInfo,
)
from sentry.integrations.source_code_management.repository import RepositoryClient
from sentry.models.pullrequest import PullRequest, PullRequestComment
from sentry.models.repository import Repository

logger = logging.getLogger(__name__)


class PerforceClient(RepositoryClient, CommitContextClient):
"""
Client for interacting with Perforce server.
Uses P4Python library to execute P4 commands.
"""

def __init__(
self,
host: str | None = None,
port: int | str | None = None,
user: str | None = None,
password: str | None = None,
client: str | None = None,
ticket: str | None = None,
):
self.ticket = ticket
self.host = host or "localhost"
self.port = str(port) if port else "1666"
self.user = user or ""
self.password = password
self.client_name = client
self.base_url = f"p4://{self.host}:{self.port}"

def _connect(self):
"""Create and connect a P4 instance."""
pass

def _disconnect(self, p4):
"""Disconnect P4 instance."""
pass

def check_file(self, repo: Repository, path: str, version: str | None) -> object | None:
"""
Check if a file exists in the depot.

Args:
repo: Repository object containing depot path (includes stream if specified)
path: File path relative to depot
version: Not used (streams are part of depot_path)

Returns:
File info dict if exists, None otherwise
"""
return None

def get_file(
self, repo: Repository, path: str, ref: str | None, codeowners: bool = False
) -> str:
"""
Get file contents from depot.

Args:
repo: Repository object (depot_path includes stream if specified)
path: File path
ref: Not used (streams are part of depot_path)
codeowners: Whether this is a CODEOWNERS file

Returns:
File contents as string
"""
return ""

def _build_depot_path(self, repo: Repository, path: str, ref: str | None = None) -> str:
"""
Build full depot path from repo config and file path.

Args:
repo: Repository object
path: File path (may include @revision syntax like "file.cpp@42")
ref: Optional ref/revision (for compatibility, but Perforce uses @revision in path)

Returns:
Full depot path with @revision preserved if present
"""
return ""

def get_blame(
self, repo: Repository, path: str, ref: str | None = None, lineno: int | None = None
) -> list[dict[str, Any]]:
"""
Get blame/annotate information for a file (like git blame).

Uses 'p4 filelog' + 'p4 describe' which is much faster than 'p4 annotate'.
Returns the most recent changelist that modified the file and its author.
This is used for CODEOWNERS-style ownership detection.

Args:
repo: Repository object (depot_path includes stream if specified)
path: File path relative to depot (may include @revision like "file.cpp@42")
ref: Optional revision/changelist number (appended as @ref if not in path)
lineno: Specific line number to blame (optional, currently ignored)

Returns:
List with a single entry containing:
- changelist: changelist number
- user: username who made the change
- date: date of change
- description: changelist description
"""
return []

def get_depot_info(self) -> dict[str, Any]:
"""
Get server info for testing connection.

Returns:
Server info dictionary
"""
return {}

def get_depots(self) -> list[dict[str, Any]]:
"""
List all depots accessible to the user.

Returns:
List of depot info dictionaries
"""
return []

def get_changes(
self, depot_path: str, max_changes: int = 20, start_cl: str | None = None
) -> list[dict[str, Any]]:
"""
Get changelists for a depot path.

Args:
depot_path: Depot path (e.g., //depot/main/...)
max_changes: Maximum number of changes to return
start_cl: Starting changelist number

Returns:
List of changelist dictionaries
"""
return []

def get_blame_for_files(
self, files: Sequence[SourceLineInfo], extra: dict[str, Any]
) -> list[FileBlameInfo]:
"""
Get blame information for multiple files using p4 filelog.

Uses 'p4 filelog' + 'p4 describe' which is much faster than 'p4 annotate'.
Returns the most recent changelist that modified each file.

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.

Returns a list of FileBlameInfo objects containing commit details for each file.
"""
return []

def create_comment(self, repo: str, issue_id: str, data: dict[str, Any]) -> Any:
"""Create comment. Not applicable for Perforce."""
raise NotImplementedError("Perforce does not support issue comments")

def update_comment(
self, repo: str, issue_id: str, comment_id: str, data: dict[str, Any]
) -> Any:
"""Update comment. Not applicable for Perforce."""
raise NotImplementedError("Perforce does not support issue comments")

def create_pr_comment(self, repo: Repository, pr: PullRequest, data: dict[str, Any]) -> Any:
"""Create PR comment. Not applicable for Perforce."""
raise NotImplementedError("Perforce does not have native pull requests")

def update_pr_comment(
self,
repo: Repository,
pr: PullRequest,
pr_comment: PullRequestComment,
data: dict[str, Any],
) -> Any:
"""Update PR comment. Not applicable for Perforce."""
raise NotImplementedError("Perforce does not have native pull requests")

def get_merge_commit_sha_from_commit(self, repo: Repository, sha: str) -> str | None:
"""Get merge commit. Not applicable for Perforce."""
return None
Loading
Loading