Skip to content

Commit 54a6e09

Browse files
committed
feat(perforce): Add Perforce integration infrastructure and stubs
This PR adds the boilerplate and infrastructure for Perforce/Helix Core integration: - Type definitions: Added PERFORCE to ExternalProviders and IntegrationProviderSlug - Model updates: Added Perforce to external actor provider choices - Integration registration: Registered PerforceIntegrationProvider - Feature flag: Added organizations:integrations-perforce - Dependencies: Added p4python package - Stub implementations: Created empty Perforce integration, client, and repository classes with all method signatures All methods are stubbed out with proper type hints and docstrings. Implementation will be added in a follow-up PR.
1 parent 753774b commit 54a6e09

File tree

9 files changed

+594
-0
lines changed

9 files changed

+594
-0
lines changed

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ dependencies = [
5353
"objectstore-client>=0.0.5",
5454
"openai>=1.3.5",
5555
"orjson>=3.10.10",
56+
"p4python>=2025.1.2767466",
5657
"packaging>=24.1",
5758
"parsimonious>=0.10.0",
5859
"petname>=2.6",
@@ -295,6 +296,7 @@ module = [
295296
"onelogin.saml2.auth.*",
296297
"onelogin.saml2.constants.*",
297298
"onelogin.saml2.idp_metadata_parser.*",
299+
"P4",
298300
"rb.*",
299301
"statsd.*",
300302
"tokenizers.*",

src/sentry/conf/server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2149,6 +2149,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
21492149
"sentry.integrations.discord.DiscordIntegrationProvider",
21502150
"sentry.integrations.opsgenie.OpsgenieIntegrationProvider",
21512151
"sentry.integrations.cursor.integration.CursorAgentIntegrationProvider",
2152+
"sentry.integrations.perforce.integration.PerforceIntegrationProvider",
21522153
)
21532154

21542155

src/sentry/integrations/api/bases/external_actor.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
ExternalProviders.SLACK,
3535
ExternalProviders.MSTEAMS,
3636
ExternalProviders.JIRA_SERVER,
37+
ExternalProviders.PERFORCE,
3738
ExternalProviders.CUSTOM,
3839
}
3940

src/sentry/integrations/models/external_actor.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class ExternalActor(ReplicatedRegionModel):
4141
(ExternalProviders.GITHUB_ENTERPRISE, IntegrationProviderSlug.GITHUB_ENTERPRISE.value),
4242
(ExternalProviders.GITLAB, IntegrationProviderSlug.GITLAB.value),
4343
(ExternalProviders.JIRA_SERVER, IntegrationProviderSlug.JIRA_SERVER.value),
44+
(ExternalProviders.PERFORCE, IntegrationProviderSlug.PERFORCE.value),
4445
# TODO: do migration to delete this from database
4546
(ExternalProviders.CUSTOM, "custom_scm"),
4647
),
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from __future__ import annotations
2+
3+
from .integration import PerforceIntegration, PerforceIntegrationProvider
4+
from .repository import PerforceRepositoryProvider
5+
6+
__all__ = ["PerforceIntegration", "PerforceIntegrationProvider", "PerforceRepositoryProvider"]
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from collections.abc import Sequence
5+
from typing import Any
6+
7+
from sentry.integrations.source_code_management.commit_context import (
8+
CommitContextClient,
9+
FileBlameInfo,
10+
SourceLineInfo,
11+
)
12+
from sentry.integrations.source_code_management.repository import RepositoryClient
13+
from sentry.models.pullrequest import PullRequest, PullRequestComment
14+
from sentry.models.repository import Repository
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
class PerforceClient(RepositoryClient, CommitContextClient):
20+
"""
21+
Client for interacting with Perforce server.
22+
Uses P4Python library to execute P4 commands.
23+
"""
24+
25+
def __init__(
26+
self,
27+
host: str | None = None,
28+
port: int | str | None = None,
29+
user: str | None = None,
30+
password: str | None = None,
31+
client: str | None = None,
32+
ticket: str | None = None,
33+
):
34+
self.ticket = ticket
35+
self.host = host or "localhost"
36+
self.port = str(port) if port else "1666"
37+
self.user = user or ""
38+
self.password = password
39+
self.client_name = client
40+
self.base_url = f"p4://{self.host}:{self.port}"
41+
42+
def _connect(self):
43+
"""Create and connect a P4 instance."""
44+
pass
45+
46+
def _disconnect(self, p4):
47+
"""Disconnect P4 instance."""
48+
pass
49+
50+
def check_file(self, repo: Repository, path: str, version: str | None) -> object | None:
51+
"""
52+
Check if a file exists in the depot.
53+
54+
Args:
55+
repo: Repository object containing depot path (includes stream if specified)
56+
path: File path relative to depot
57+
version: Not used (streams are part of depot_path)
58+
59+
Returns:
60+
File info dict if exists, None otherwise
61+
"""
62+
return None
63+
64+
def get_file(
65+
self, repo: Repository, path: str, ref: str | None, codeowners: bool = False
66+
) -> str:
67+
"""
68+
Get file contents from depot.
69+
70+
Args:
71+
repo: Repository object (depot_path includes stream if specified)
72+
path: File path
73+
ref: Not used (streams are part of depot_path)
74+
codeowners: Whether this is a CODEOWNERS file
75+
76+
Returns:
77+
File contents as string
78+
"""
79+
return ""
80+
81+
def _build_depot_path(self, repo: Repository, path: str, ref: str | None = None) -> str:
82+
"""
83+
Build full depot path from repo config and file path.
84+
85+
Args:
86+
repo: Repository object
87+
path: File path (may include @revision syntax like "file.cpp@42")
88+
ref: Optional ref/revision (for compatibility, but Perforce uses @revision in path)
89+
90+
Returns:
91+
Full depot path with @revision preserved if present
92+
"""
93+
return ""
94+
95+
def get_blame(
96+
self, repo: Repository, path: str, ref: str | None = None, lineno: int | None = None
97+
) -> list[dict[str, Any]]:
98+
"""
99+
Get blame/annotate information for a file (like git blame).
100+
101+
Uses 'p4 filelog' + 'p4 describe' which is much faster than 'p4 annotate'.
102+
Returns the most recent changelist that modified the file and its author.
103+
This is used for CODEOWNERS-style ownership detection.
104+
105+
Args:
106+
repo: Repository object (depot_path includes stream if specified)
107+
path: File path relative to depot (may include @revision like "file.cpp@42")
108+
ref: Optional revision/changelist number (appended as @ref if not in path)
109+
lineno: Specific line number to blame (optional, currently ignored)
110+
111+
Returns:
112+
List with a single entry containing:
113+
- changelist: changelist number
114+
- user: username who made the change
115+
- date: date of change
116+
- description: changelist description
117+
"""
118+
return []
119+
120+
def get_depot_info(self) -> dict[str, Any]:
121+
"""
122+
Get server info for testing connection.
123+
124+
Returns:
125+
Server info dictionary
126+
"""
127+
return {}
128+
129+
def get_depots(self) -> list[dict[str, Any]]:
130+
"""
131+
List all depots accessible to the user.
132+
133+
Returns:
134+
List of depot info dictionaries
135+
"""
136+
return []
137+
138+
def get_changes(
139+
self, depot_path: str, max_changes: int = 20, start_cl: str | None = None
140+
) -> list[dict[str, Any]]:
141+
"""
142+
Get changelists for a depot path.
143+
144+
Args:
145+
depot_path: Depot path (e.g., //depot/main/...)
146+
max_changes: Maximum number of changes to return
147+
start_cl: Starting changelist number
148+
149+
Returns:
150+
List of changelist dictionaries
151+
"""
152+
return []
153+
154+
def get_blame_for_files(
155+
self, files: Sequence[SourceLineInfo], extra: dict[str, Any]
156+
) -> list[FileBlameInfo]:
157+
"""
158+
Get blame information for multiple files using p4 filelog.
159+
160+
Uses 'p4 filelog' + 'p4 describe' which is much faster than 'p4 annotate'.
161+
Returns the most recent changelist that modified each file.
162+
163+
Note: This does not provide line-specific blame. It returns the most recent
164+
changelist for the entire file, which is sufficient for suspect commit detection.
165+
166+
Returns a list of FileBlameInfo objects containing commit details for each file.
167+
"""
168+
return []
169+
170+
def create_comment(self, repo: str, issue_id: str, data: dict[str, Any]) -> Any:
171+
"""Create comment. Not applicable for Perforce."""
172+
raise NotImplementedError("Perforce does not support issue comments")
173+
174+
def update_comment(
175+
self, repo: str, issue_id: str, comment_id: str, data: dict[str, Any]
176+
) -> Any:
177+
"""Update comment. Not applicable for Perforce."""
178+
raise NotImplementedError("Perforce does not support issue comments")
179+
180+
def create_pr_comment(self, repo: Repository, pr: PullRequest, data: dict[str, Any]) -> Any:
181+
"""Create PR comment. Not applicable for Perforce."""
182+
raise NotImplementedError("Perforce does not have native pull requests")
183+
184+
def update_pr_comment(
185+
self,
186+
repo: Repository,
187+
pr: PullRequest,
188+
pr_comment: PullRequestComment,
189+
data: dict[str, Any],
190+
) -> Any:
191+
"""Update PR comment. Not applicable for Perforce."""
192+
raise NotImplementedError("Perforce does not have native pull requests")
193+
194+
def get_merge_commit_sha_from_commit(self, repo: Repository, sha: str) -> str | None:
195+
"""Get merge commit. Not applicable for Perforce."""
196+
return None

0 commit comments

Comments
 (0)