Skip to content

Commit 2172a79

Browse files
committed
feat(perforce): Add backend support for Perforce integration
This commit adds backend support for Perforce version control integration: - New Perforce integration with P4 client support - Repository and code mapping functionality - Stacktrace linking for Perforce depot paths - Tests for integration, code mapping, and stacktrace linking - Updated dependencies in pyproject.toml The integration supports: - Authentication via P4PORT, P4USER, P4PASSWD - Code mapping between depot paths and project structure - Source URL generation for stacktrace frames - Integration with Sentry's repository and code mapping systems
1 parent 8e8a4c0 commit 2172a79

File tree

10 files changed

+2033
-31
lines changed

10 files changed

+2033
-31
lines changed

src/sentry/integrations/api/endpoints/organization_code_mappings.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ class RepositoryProjectPathConfigSerializer(CamelSnakeModelSerializer):
4242
source_root = gen_path_regex_field()
4343
default_branch = serializers.RegexField(
4444
r"^(^(?![\/]))([\w\.\/-]+)(?<![\/])$",
45-
required=True,
45+
required=False, # Validated in validate_default_branch based on integration type
46+
allow_blank=True, # Perforce allows empty streams
4647
error_messages={"invalid": _(BRANCH_NAME_ERROR_MESSAGE)},
4748
)
4849
instance: RepositoryProjectPathConfig | None
@@ -98,6 +99,24 @@ def validate_project_id(self, project_id):
9899
raise serializers.ValidationError("Project does not exist")
99100
return project_id
100101

102+
def validate_default_branch(self, default_branch):
103+
# Get the integration to check if it's Perforce
104+
integration = integration_service.get_integration(
105+
integration_id=self.org_integration.integration_id
106+
)
107+
108+
# For Perforce, allow empty branch (streams are part of depot path)
109+
if integration and integration.provider == "perforce":
110+
# Allow empty string for Perforce
111+
if not default_branch:
112+
return default_branch
113+
else:
114+
# For other integrations, branch is required
115+
if not default_branch:
116+
raise serializers.ValidationError("This field is required.")
117+
118+
return default_branch
119+
101120
def create(self, validated_data):
102121
return RepositoryProjectPathConfig.objects.create(
103122
organization_integration_id=self.org_integration.id,

src/sentry/integrations/perforce/client.py

Lines changed: 264 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,22 @@
22

33
import logging
44
from collections.abc import Sequence
5+
from datetime import datetime, timezone
56
from typing import Any
67

8+
from P4 import P4, P4Exception
9+
710
from sentry.integrations.source_code_management.commit_context import (
811
CommitContextClient,
12+
CommitInfo,
913
FileBlameInfo,
1014
SourceLineInfo,
1115
)
1216
from sentry.integrations.source_code_management.repository import RepositoryClient
1317
from sentry.models.pullrequest import PullRequest, PullRequestComment
1418
from sentry.models.repository import Repository
19+
from sentry.shared_integrations.exceptions import ApiError
20+
from sentry.utils import metrics
1521

1622
logger = logging.getLogger(__name__)
1723

@@ -38,14 +44,54 @@ def __init__(
3844
self.password = password
3945
self.client_name = client
4046
self.base_url = f"p4://{self.host}:{self.port}"
47+
self.P4 = P4
48+
self.P4Exception = P4Exception
4149

4250
def _connect(self):
4351
"""Create and connect a P4 instance."""
44-
pass
52+
is_ticket_auth = bool(self.ticket)
53+
54+
p4 = self.P4()
55+
56+
if is_ticket_auth:
57+
# Ticket authentication: P4Python auto-extracts host/port/user from ticket
58+
# Just set the ticket as password and P4 will handle the rest
59+
p4.password = self.ticket
60+
else:
61+
# Password authentication: set host/port/user explicitly
62+
p4.port = f"{self.host}:{self.port}"
63+
p4.user = self.user
64+
65+
if self.password:
66+
p4.password = self.password
67+
68+
if self.client_name:
69+
p4.client = self.client_name
70+
71+
p4.exception_level = 1 # Only errors raise exceptions
72+
73+
try:
74+
p4.connect()
75+
76+
# Authenticate with the server if password is provided (not ticket)
77+
if self.password and not is_ticket_auth:
78+
try:
79+
p4.run_login()
80+
except self.P4Exception as login_error:
81+
p4.disconnect()
82+
raise ApiError(f"Failed to authenticate with Perforce: {login_error}")
83+
84+
return p4
85+
except self.P4Exception as e:
86+
raise ApiError(f"Failed to connect to Perforce: {e}")
4587

4688
def _disconnect(self, p4):
4789
"""Disconnect P4 instance."""
48-
pass
90+
try:
91+
if p4.connected():
92+
p4.disconnect()
93+
except Exception:
94+
pass
4995

5096
def check_file(self, repo: Repository, path: str, version: str | None) -> object | None:
5197
"""
@@ -59,7 +105,19 @@ def check_file(self, repo: Repository, path: str, version: str | None) -> object
59105
Returns:
60106
File info dict if exists, None otherwise
61107
"""
62-
return None
108+
p4 = self._connect()
109+
try:
110+
depot_path = self._build_depot_path(repo, path)
111+
result = p4.run("files", depot_path)
112+
113+
if result and len(result) > 0:
114+
return result[0]
115+
return None
116+
117+
except self.P4Exception:
118+
return None
119+
finally:
120+
self._disconnect(p4)
63121

64122
def get_file(
65123
self, repo: Repository, path: str, ref: str | None, codeowners: bool = False
@@ -76,7 +134,22 @@ def get_file(
76134
Returns:
77135
File contents as string
78136
"""
79-
return ""
137+
p4 = self._connect()
138+
try:
139+
depot_path = self._build_depot_path(repo, path)
140+
result = p4.run("print", "-q", depot_path)
141+
142+
if result and len(result) > 1:
143+
# First element is file info, second is content
144+
return result[1]
145+
146+
raise ApiError(f"File not found: {depot_path}")
147+
148+
except self.P4Exception as e:
149+
logger.exception("perforce.get_file_failed", extra={"path": path})
150+
raise ApiError(f"Failed to get file: {e}")
151+
finally:
152+
self._disconnect(p4)
80153

81154
def _build_depot_path(self, repo: Repository, path: str, ref: str | None = None) -> str:
82155
"""
@@ -90,7 +163,21 @@ def _build_depot_path(self, repo: Repository, path: str, ref: str | None = None)
90163
Returns:
91164
Full depot path with @revision preserved if present
92165
"""
93-
return ""
166+
depot_root = repo.config.get("depot_path", repo.name).rstrip("/")
167+
168+
# Ensure path doesn't start with /
169+
path = path.lstrip("/")
170+
171+
# If path contains @revision, preserve it (e.g., "file.cpp@42")
172+
# If ref is provided and path doesn't have @revision, append it
173+
full_path = f"{depot_root}/{path}"
174+
175+
# If ref is provided and path doesn't already have @revision, append it
176+
# Skip "master" as it's a Git concept and not valid in Perforce
177+
if ref and "@" not in path and ref != "master":
178+
full_path = f"{full_path}@{ref}"
179+
180+
return full_path
94181

95182
def get_blame(
96183
self, repo: Repository, path: str, ref: str | None = None, lineno: int | None = None
@@ -115,7 +202,50 @@ def get_blame(
115202
- date: date of change
116203
- description: changelist description
117204
"""
118-
return []
205+
p4 = self._connect()
206+
try:
207+
depot_path = self._build_depot_path(repo, path, ref)
208+
209+
# Use 'p4 filelog' to get the most recent changelist for this file
210+
# This is much faster than 'p4 annotate'
211+
# If depot_path includes @revision, filelog will show history up to that revision
212+
filelog = p4.run("filelog", "-m1", depot_path)
213+
214+
if not filelog or len(filelog) == 0:
215+
return []
216+
217+
# Get the most recent changelist number
218+
# filelog returns a list of revisions, we want the first one
219+
revisions = filelog[0].get("rev", [])
220+
if not revisions or len(revisions) == 0:
221+
return []
222+
223+
# Get the changelist number from the first revision
224+
changelist = revisions[0].get("change")
225+
if not changelist:
226+
return []
227+
228+
# Get detailed changelist information using 'p4 describe'
229+
# -s flag means "short" - don't include diffs, just metadata
230+
change_info = p4.run("describe", "-s", changelist)
231+
232+
if not change_info:
233+
return []
234+
235+
change_data = change_info[0]
236+
return [
237+
{
238+
"changelist": str(changelist),
239+
"user": change_data.get("user", "unknown"),
240+
"date": change_data.get("time", ""),
241+
"description": change_data.get("desc", ""),
242+
}
243+
]
244+
245+
except self.P4Exception:
246+
return []
247+
finally:
248+
self._disconnect(p4)
119249

120250
def get_depot_info(self) -> dict[str, Any]:
121251
"""
@@ -124,7 +254,17 @@ def get_depot_info(self) -> dict[str, Any]:
124254
Returns:
125255
Server info dictionary
126256
"""
127-
return {}
257+
p4 = self._connect()
258+
try:
259+
info = p4.run("info")[0]
260+
return {
261+
"server_address": info.get("serverAddress"),
262+
"server_version": info.get("serverVersion"),
263+
"user": info.get("userName"),
264+
"client": info.get("clientName"),
265+
}
266+
finally:
267+
self._disconnect(p4)
128268

129269
def get_depots(self) -> list[dict[str, Any]]:
130270
"""
@@ -133,7 +273,19 @@ def get_depots(self) -> list[dict[str, Any]]:
133273
Returns:
134274
List of depot info dictionaries
135275
"""
136-
return []
276+
p4 = self._connect()
277+
try:
278+
depots = p4.run("depots")
279+
return [
280+
{
281+
"name": depot.get("name"),
282+
"type": depot.get("type"),
283+
"description": depot.get("desc", ""),
284+
}
285+
for depot in depots
286+
]
287+
finally:
288+
self._disconnect(p4)
137289

138290
def get_changes(
139291
self, depot_path: str, max_changes: int = 20, start_cl: str | None = None
@@ -149,7 +301,31 @@ def get_changes(
149301
Returns:
150302
List of changelist dictionaries
151303
"""
152-
return []
304+
p4 = self._connect()
305+
try:
306+
args = ["-m", str(max_changes), "-l"]
307+
308+
if start_cl:
309+
args.extend(["-e", start_cl])
310+
311+
args.append(depot_path)
312+
313+
changes = p4.run("changes", *args)
314+
315+
return [
316+
{
317+
"change": change.get("change"),
318+
"user": change.get("user"),
319+
"client": change.get("client"),
320+
"time": change.get("time"),
321+
"desc": change.get("desc"),
322+
}
323+
for change in changes
324+
]
325+
finally:
326+
self._disconnect(p4)
327+
328+
# CommitContextClient methods (stubbed for now)
153329

154330
def get_blame_for_files(
155331
self, files: Sequence[SourceLineInfo], extra: dict[str, Any]
@@ -165,7 +341,85 @@ def get_blame_for_files(
165341
166342
Returns a list of FileBlameInfo objects containing commit details for each file.
167343
"""
168-
return []
344+
metrics.incr("integrations.perforce.get_blame_for_files")
345+
blames = []
346+
p4 = self._connect()
347+
348+
try:
349+
for file in files:
350+
try:
351+
# Build depot path for the file (includes stream if specified)
352+
# file.ref contains the revision/changelist if available
353+
depot_path = self._build_depot_path(file.repo, file.path, file.ref)
354+
355+
# Use faster p4 filelog approach to get most recent changelist
356+
# This is much faster than p4 annotate
357+
filelog = p4.run("filelog", "-m1", depot_path)
358+
359+
changelist = None
360+
if filelog and len(filelog) > 0:
361+
# The 'change' field contains the changelist numbers (as a list of strings)
362+
changelists = filelog[0].get("change", [])
363+
if changelists and len(changelists) > 0:
364+
# Get the first (most recent) changelist number
365+
changelist = changelists[0]
366+
367+
# If we found a changelist, get detailed commit info
368+
if changelist:
369+
try:
370+
change_info = p4.run("describe", "-s", changelist)
371+
if change_info and len(change_info) > 0:
372+
change = change_info[0]
373+
username = change.get("user", "unknown")
374+
# Perforce doesn't provide email by default, so we generate a fallback
375+
email = change.get("email") or f"{username}@perforce.local"
376+
commit = CommitInfo(
377+
commitId=changelist,
378+
committedDate=datetime.fromtimestamp(
379+
int(change.get("time", 0)), tz=timezone.utc
380+
),
381+
commitMessage=change.get("desc", "").strip(),
382+
commitAuthorName=username,
383+
commitAuthorEmail=email,
384+
)
385+
386+
blame_info = FileBlameInfo(
387+
lineno=file.lineno,
388+
path=file.path,
389+
ref=file.ref,
390+
repo=file.repo,
391+
code_mapping=file.code_mapping,
392+
commit=commit,
393+
)
394+
blames.append(blame_info)
395+
except self.P4Exception as e:
396+
logger.warning(
397+
"perforce.client.get_blame_for_files.describe_error",
398+
extra={
399+
**extra,
400+
"changelist": changelist,
401+
"error": str(e),
402+
"repo_name": file.repo.name,
403+
"file_path": file.path,
404+
},
405+
)
406+
except self.P4Exception as e:
407+
# Log but don't fail for individual file errors
408+
logger.warning(
409+
"perforce.client.get_blame_for_files.annotate_error",
410+
extra={
411+
**extra,
412+
"error": str(e),
413+
"repo_name": file.repo.name,
414+
"file_path": file.path,
415+
"file_lineno": file.lineno,
416+
},
417+
)
418+
continue
419+
420+
return blames
421+
finally:
422+
self._disconnect(p4)
169423

170424
def create_comment(self, repo: str, issue_id: str, data: dict[str, Any]) -> Any:
171425
"""Create comment. Not applicable for Perforce."""

0 commit comments

Comments
 (0)