22
33import logging
44from collections .abc import Sequence
5+ from datetime import datetime , timezone
56from typing import Any
67
8+ from P4 import P4 , P4Exception
9+
710from sentry .integrations .source_code_management .commit_context import (
811 CommitContextClient ,
12+ CommitInfo ,
913 FileBlameInfo ,
1014 SourceLineInfo ,
1115)
1216from sentry .integrations .source_code_management .repository import RepositoryClient
1317from sentry .models .pullrequest import PullRequest , PullRequestComment
1418from sentry .models .repository import Repository
19+ from sentry .shared_integrations .exceptions import ApiError
20+ from sentry .utils import metrics
1521
1622logger = 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