2023-07-19 09:14:51 -04:00
// Copyright Epic Games, Inc. All Rights Reserved.
using System ;
using System.Collections.Generic ;
using Microsoft.Extensions.Logging ;
using System.Linq ;
using UnrealBuildTool ;
2023-11-14 09:40:24 -05:00
using UnrealBuildBase ;
2023-07-19 09:14:51 -04:00
namespace AutomationTool
{
[Help("Bisects a range of changelists between 'good' and 'bad' (exclusive). If a failed-build is reported, then bisection expands outwards from the active changelist until bisection resumed.")]
[Help("TargetStream=<Stream>", "Stream path. (Required)")]
[Help("RootPath=<Stream>", "Local root path (if not specified current client root path will be used (not recommended as some files used by the script might be replaced while syncing)). (Optional)")]
[Help("GoodCL=<CL>", "First changelist that is in good shape. (Required)")]
[Help("BadCL=<CL>", "Changelist that is known to be bad. (Required)")]
[Help("Project=<Project.uproject>", "Project file name to be synced. (Required)")]
[Help("TestsToCheck", "Test list to check the tests should be separated by semicolon. (Optional)")]
[RequireP4]
class Bisect : BuildCommand
{
/// <summary>
/// The class is a helper class for bisection method.
/// <para>
/// It stores a range of changelist indexes (between 'good' and 'bad' (exclusive)) to support bisection of the current range by calling <c>Bad</c> and <c>Good</c> methods.
/// The class also supports <c>Ugly</c> method to label current changelist index with 'failed to build' mark.
/// </para>
/// </summary>
private class Bisectomatron
{
public int GoodIndex { get ; private set ; }
public int BadIndex { get ; private set ; }
public int Index { get ; private set ; }
private int Jump ;
private HashSet < int > UglyIndexes ;
/// <summary>
/// The class constructor
/// </summary>
/// <param name="Length">Length of the desired range.</param>
public Bisectomatron ( int Length )
{
GoodIndex = 0 ;
BadIndex = Length - 1 ;
Jump = - 1 ;
UglyIndexes = new HashSet < int > ( ) ;
Bisect ( ) ;
}
/// <summary>
/// The method is designed to check whether the object of the class is still valid to proceed bisection.
/// </summary>
/// <param name="BisectomatronInstance">The object to check whether it is valid.</param>
public static implicit operator bool ( Bisectomatron BisectomatronInstance )
{
return ( BisectomatronInstance . BadIndex - BisectomatronInstance . GoodIndex ) > ( BisectomatronInstance . UglyIndexes . Count + 1 ) ;
}
/// <summary>
/// The method performs bisection of the current indexes range with taking into account indexes of changelists that were not built correctly.
/// </summary>
private void Bisect ( )
{
Index = ( GoodIndex + BadIndex ) / 2 ;
while ( UglyIndexes . Contains ( Index ) )
{
Index + = Jump ;
}
}
/// <summary>
/// The method handles current index as 'good' (build and test runs for the corresponding changelist were completed successfully) and proceed bisection if possible.
/// </summary>
public void Good ( )
{
UglyIndexes . RemoveWhere ( UglyIndex = > ( UglyIndex < = Index ) ) ;
GoodIndex = Index ;
Jump = 1 ;
Bisect ( ) ;
}
/// <summary>
/// The method handles current index as 'bad' (test run for the corresponding changelist was completed with error) and proceed bisection if possible.
/// </summary>
public void Bad ( )
{
UglyIndexes . RemoveWhere ( UglyIndex = > ( UglyIndex > = Index ) ) ;
BadIndex = Index ;
Jump = - 1 ;
Bisect ( ) ;
}
/// <summary>
/// The method handles current index as 'ugly' (build of corresponding changelist was completed with error) and tries to determine the closest neighboring index that has not been checked yet.
/// <para>
/// While searching for the closest neighboring index that has not been checked yet the method goes in two directions.
/// It is performed by alternating the closest left hand side and right hand side neighbors that have not been handled yet.
/// </para>
/// </summary>
public void Ugly ( )
{
UglyIndexes . Add ( Index ) ;
// try to set the nearest neighbor index that is not in UglyIndexes.
while ( this )
{
int CurrentJump = Jump ;
Jump = - CurrentJump - ( CurrentJump / Math . Abs ( CurrentJump ) ) ;
Index + = CurrentJump ;
if ( ! UglyIndexes . Contains ( Index ) )
{
break ;
}
}
}
}
/// <summary>
/// The class is a storage class for the project file information.
/// </summary>
private class ProjectFileInfo
{
public string DepotPath { get ; set ; }
public string FilePath { get ; set ; }
/// <summary>
/// Checks if the stored data is valid.
/// </summary>
/// <returns>True if the corresponding object stores valid data, otherwise - false.</returns>
public bool IsValid ( )
{
return
( ! String . IsNullOrEmpty ( DepotPath ) ) & &
( ! String . IsNullOrEmpty ( FilePath ) ) ;
}
/// <summary>
/// The method returns depot path for the corresponding project file.
/// </summary>
/// <returns>Depot path for the corresponding project file or null if it can not be determined.</returns>
public string GetDepotDirPath ( )
{
if ( String . IsNullOrEmpty ( DepotPath ) )
{
return null ;
}
string [ ] DepotPathParts = DepotPath . Split ( "/" ) ;
string [ ] DepotDirPathParts = DepotPathParts . Take ( DepotPathParts . Length - 1 ) . ToArray ( ) ;
return String . Join ( "/" , DepotDirPathParts ) ;
}
}
private static readonly List < string > RequiredDirsToSync = new List < string > { "Engine" } ;
private static readonly List < string > DirsToExcludeFromSync = new List < string > {
"Engine/Binaries/ThirdParty/DotNet" ,
"Engine/Source/Programs/AutomationTool"
} ;
private string TargetStream { get ; set ; }
private string RootPath { get ; set ; }
private string Project { get ; set ; }
private int GoodCL { get ; set ; }
private int BadCL { get ; set ; }
private string TestsToCheck { get ; set ; }
private List < string > GetRequiredDirsToSync ( )
{
List < string > Result = new List < string > ( ) ;
foreach ( string Dir in RequiredDirsToSync )
{
Result . Add ( String . Format ( "{0}/{1}" , TargetStream , Dir ) ) ;
}
return Result ;
}
private List < KeyValuePair < string , string > > GetExclusionViewParts ( )
{
List < KeyValuePair < string , string > > Result = new List < KeyValuePair < string , string > > ( ) ;
// Apply exclusion list if and only if RootPath is not temporary (e.g it is local root)
if ( RootPath = = CommandUtils . CmdEnv . LocalRoot )
{
foreach ( string Dir in DirsToExcludeFromSync )
{
Result . Add ( new KeyValuePair < string , string > (
String . Format ( "-{0}/{1}/..." , TargetStream , Dir ) ,
String . Format ( "/{0}/..." , Dir ) ) ) ;
}
}
return Result ;
}
private void GetEntriesToSync ( P4Connection Perforce , int CL , ProjectFileInfo ProjectFileInfo , out List < string > Dirs , out List < string > Files )
{
Dirs = GetRequiredDirsToSync ( ) ;
string ProjectDirDepotPath = ProjectFileInfo . GetDepotDirPath ( ) ;
if ( ! String . IsNullOrEmpty ( ProjectDirDepotPath ) )
{
string [ ] ProjectDirDepotPathParts = ProjectDirDepotPath . Split ( "/" ) ;
if ( ! Dirs . Any ( Dir = > {
string [ ] DirPathParts = Dir . Split ( "/" ) ;
int PartsToCompareCount = Math . Min ( DirPathParts . Length , ProjectDirDepotPathParts . Length ) ;
bool bDirsHasTheSameDepotPathStart = Enumerable . SequenceEqual ( ProjectDirDepotPathParts . Take ( PartsToCompareCount ) , DirPathParts . Take ( PartsToCompareCount ) ) ;
return bDirsHasTheSameDepotPathStart ;
} ) )
{
Dirs . Add ( ProjectDirDepotPath ) ;
}
}
string FStatOutput ;
// Note that the command line containt '*' symbol. Because of that all the additional parameters after fstat will be placed into a temorary file.
// Every parameter in this temporary file should be in a separate line (using of '\n' instead of ' ').
string FStatCommandLine = String . Format ( "fstat -Olhp\n-Dl\n-F\n^headAction=delete & ^headAction=move/delete\n{0}/*@{1}" , TargetStream , CL ) ;
if ( ! Perforce . LogP4Output ( out FStatOutput , "" , FStatCommandLine ) )
{
throw new AutomationException ( String . Format ( "p4 {0} failed" , FStatCommandLine ) ) ;
}
Files = new List < string > ( ) ;
FStatOutput = FStatOutput . Replace ( "\r" , "" ) ;
string [ ] OutputLines = FStatOutput . Split ( "\n" ) ;
string FilePrefix = new string ( "... depotFile " ) ;
foreach ( string OutputLine in OutputLines )
{
string TrimmedOutputLine = OutputLine . Trim ( ) ;
if ( TrimmedOutputLine . StartsWith ( FilePrefix ) )
{
Files . Add ( TrimmedOutputLine . Substring ( FilePrefix . Length ) ) ;
}
}
}
private ProjectFileInfo GetProjectFileInfo ( P4Connection Perforce )
{
if ( String . IsNullOrEmpty ( TargetStream ) | | String . IsNullOrEmpty ( Project ) )
{
return null ;
}
string FStatOutput ;
string FStatCommandLine = String . Format ( "fstat -Olhp -Dl -F \"^headAction=delete & ^headAction=move/delete\" {0}/.../{1}" , TargetStream , Project ) ;
Perforce . LogP4Output ( out FStatOutput , "" , FStatCommandLine ) ;
FStatOutput = FStatOutput . Replace ( "\r" , "" ) ;
string [ ] OutputLines = FStatOutput . Split ( "\n" ) ;
string DepotFilePathPrefix = new string ( "... depotFile " ) ;
string FilePathPrefix = new string ( "... path " ) ;
ProjectFileInfo Result = new ProjectFileInfo ( ) ;
foreach ( string OutputLine in OutputLines )
{
string TrimmedOutputLine = OutputLine . Trim ( ) ;
if ( TrimmedOutputLine . EndsWith ( Project ) )
{
if ( TrimmedOutputLine . StartsWith ( DepotFilePathPrefix ) )
{
Result . DepotPath = TrimmedOutputLine . Substring ( DepotFilePathPrefix . Length ) ;
}
else if ( TrimmedOutputLine . StartsWith ( FilePathPrefix ) )
{
Result . FilePath = TrimmedOutputLine . Substring ( FilePathPrefix . Length ) ;
}
if ( Result . IsValid ( ) )
{
return Result ;
}
}
}
return null ;
}
private bool RunExecutableInRootEnvironment ( string ExecutablePath , string CommandLineArgs )
{
string DirectoryToRestore = Environment . CurrentDirectory ;
if ( ! CommandUtils . ChDir_NoExceptions ( RootPath ) )
{
return false ;
}
Dictionary < string , string > OverridenEnv = new Dictionary < string , string > ( ) ;
OverridenEnv . Add ( "uebp_FinalLogFolder" , CommandUtils . CombinePaths ( RootPath , "Engine/Programs/AutomationTool/Saved/Logs" ) ) ;
OverridenEnv . Add ( "uebp_EngineSavedFolder" , CommandUtils . CombinePaths ( RootPath , "Engine/Programs/AutomationTool/Saved" ) ) ;
OverridenEnv . Add ( "uebp_LOCAL_ROOT" , RootPath ) ;
OverridenEnv . Add ( "uebp_LogFolder" , CommandUtils . CombinePaths ( RootPath , "Engine/Programs/AutomationTool/Saved/Logs" ) ) ;
IProcessResult ProcessResult ;
try
{
ProcessResult = CommandUtils . Run ( ExecutablePath , CommandLineArgs , null , ERunOptions . Default , OverridenEnv , null , "Bisect.BuildProject" , RootPath ) ;
}
catch
{
return false ;
}
finally
{
CommandUtils . ChDir_NoExceptions ( DirectoryToRestore ) ;
}
return ProcessResult . ExitCode = = 0 ;
}
private bool BuildProject ( ProjectFileInfo ProjectFileInfo )
{
string CommandLineArgs = String . Format ( "BuildCookRun -project={0} -platform={1} -build" , ProjectFileInfo . FilePath , HostPlatform . Current . HostEditorPlatform . ToString ( ) ) ;
bool bResult = true ;
if ( RootPath = = CommandUtils . CmdEnv . LocalRoot )
{
try
{
CommandUtils . RunUAT ( CommandUtils . CmdEnv , CommandLineArgs , "BisectBuild" ) ;
}
catch
{
bResult = false ;
}
}
else
{
string ExecutablePath = CommandUtils . CombinePaths ( RootPath , "RunUAT" ) ;
if ( HostPlatform . Current . HostEditorPlatform = = UnrealTargetPlatform . Win64 )
{
ExecutablePath + = ".bat" ;
}
else
{
ExecutablePath + = ".sh" ;
}
bResult = RunExecutableInRootEnvironment ( ExecutablePath , CommandLineArgs ) ;
}
return bResult ;
}
private bool RunTests ( )
{
string ExecutablePath = CommandUtils . CombinePaths ( RootPath , "Engine/Binaries" , HostPlatform . Current . HostEditorPlatform . ToString ( ) , "UnrealEditor" ) ;
if ( HostPlatform . Current . HostEditorPlatform = = UnrealTargetPlatform . Win64 )
{
ExecutablePath + = ".exe" ;
}
string CommandLineArgs = String . Format ( " {0} -ExecCmds=\"Automation RunTests {1};Quit;\"" , Project , TestsToCheck ) ;
return RunExecutableInRootEnvironment ( ExecutablePath , CommandLineArgs ) ;
}
private void ParseCommandLineArgs ( )
{
TargetStream = ParseParamValue ( "TargetStream" ) ;
if ( TargetStream = = null )
{
throw new AutomationException ( "TargetStream is not specified" ) ;
}
TargetStream = TargetStream . TrimEnd ( new char [ ] { '/' , '\\' } ) ;
if ( TargetStream . Length = = 0 )
{
throw new AutomationException ( "TargetStream is empty" ) ;
}
RootPath = ParseParamValue ( "RootPath" ) ;
if ( RootPath = = null )
{
RootPath = CommandUtils . CmdEnv . LocalRoot ;
}
else
{
RootPath = RootPath . TrimEnd ( new char [ ] { '/' , '\\' } ) ;
if ( RootPath . Length = = 0 )
{
RootPath = CommandUtils . CmdEnv . LocalRoot ;
}
}
Project = ParseParamValue ( "Project" ) ;
if ( String . IsNullOrEmpty ( Project ) )
{
throw new AutomationException ( "Project is not specified" ) ;
}
GoodCL = ParseParamInt ( "GoodCL" ) ;
BadCL = ParseParamInt ( "BadCL" ) ;
if ( GoodCL > = BadCL )
{
throw new AutomationException ( "Invalid range of changelists given (GoodCL >= BadCL)" ) ;
}
TestsToCheck = ParseParamValue ( "TestsToCheck" ) ;
}
/// <summary>
/// Executes the command
/// </summary>
public override void ExecuteBuild ( )
{
ParseCommandLineArgs ( ) ;
2023-11-14 09:40:24 -05:00
string PerforceClientName = String . Format ( "{0}_{1}_Automation_Bisect_Temp" , P4Env . User , Unreal . MachineName ) ;
2023-07-19 09:14:51 -04:00
bool bPerforceClientCreatedOutside = P4 . DoesClientExist ( PerforceClientName ) ;
if ( ! bPerforceClientCreatedOutside )
{
List < KeyValuePair < string , string > > RequiredView = new List < KeyValuePair < string , string > > ( ) ;
RequiredView . Add ( new KeyValuePair < string , string > (
String . Format ( "{0}/..." , TargetStream ) ,
String . Format ( "/..." , PerforceClientName ) ) ) ;
RequiredView . AddRange ( GetExclusionViewParts ( ) ) ;
P4ClientInfo PerforceClientInfo = new P4ClientInfo ( ) ;
PerforceClientInfo . Owner = P4Env . User ;
2023-11-14 09:40:24 -05:00
PerforceClientInfo . Host = Unreal . MachineName ;
2023-07-19 09:14:51 -04:00
PerforceClientInfo . RootPath = RootPath ;
PerforceClientInfo . Name = PerforceClientName ;
PerforceClientInfo . View = RequiredView ;
PerforceClientInfo . Stream = null ;
PerforceClientInfo . Options = P4ClientOption . NoAllWrite | P4ClientOption . Clobber | P4ClientOption . NoCompress | P4ClientOption . Unlocked | P4ClientOption . NoModTime | P4ClientOption . RmDir ;
PerforceClientInfo . LineEnd = P4LineEnd . Local ;
P4 . CreateClient ( PerforceClientInfo ) ;
}
// Sync the workspace and delete the client
try
{
P4Connection Perforce = new P4Connection ( P4Env . User , PerforceClientName ) ;
// Build p4 command line. '-L' enables changelist descriptions, '-l' enables full descriptions, '-s submitted' ignores shelves and pending changes
string ChangesCommandLine = String . Format ( "-L -l -s submitted {0}/...@{1},{2}" , TargetStream , GoodCL , BadCL ) ;
Perforce . Changes ( out List < P4Connection . ChangeRecord > SubmittedCandidateChanges , ChangesCommandLine ) ;
if ( SubmittedCandidateChanges . Count < 3 )
{
throw new AutomationException ( "Perforce returned too few changelists between {0} and {1}" , GoodCL , BadCL ) ;
}
Bisectomatron IndexGenerator = new Bisectomatron ( SubmittedCandidateChanges . Count ) ;
while ( IndexGenerator )
{
int Index = IndexGenerator . Index ;
P4Connection . ChangeRecord ChangeRecord = SubmittedCandidateChanges [ Index ] ;
int WorkingCL = ChangeRecord . CL ;
ProjectFileInfo ProjectFileInfo = GetProjectFileInfo ( Perforce ) ;
if ( ( ProjectFileInfo = = null ) | | ( ! ProjectFileInfo . IsValid ( ) ) )
{
throw new AutomationException ( "Can not determine project info for project name \"{0}\" and CL {1}" , Project , WorkingCL ) ;
}
List < string > DirsToSync ;
List < string > FilesToSync ;
GetEntriesToSync ( Perforce , WorkingCL , ProjectFileInfo , out DirsToSync , out FilesToSync ) ;
string SyncCommandLine = new string ( "" ) ;
if ( DirsToSync . Any ( ) )
{
Logger . LogInformation ( "Dirs to sync:" ) ;
foreach ( string DirToSync in DirsToSync )
{
SyncCommandLine + = String . Format ( " {0}/...@{1}" , DirToSync , WorkingCL ) ;
Logger . LogInformation ( " {0}" , DirToSync ) ;
}
}
if ( FilesToSync . Any ( ) )
{
Logger . LogInformation ( "Files to sync:" ) ;
foreach ( string FileToSync in FilesToSync )
{
SyncCommandLine + = String . Format ( " {0}@{1}" , FileToSync , WorkingCL ) ;
Logger . LogInformation ( " {0}" , FileToSync ) ;
}
}
if ( String . IsNullOrEmpty ( SyncCommandLine ) )
{
// There is nothing to sync
throw new AutomationException ( "There is nothing to sync" ) ;
}
Logger . LogInformation ( "Syncing to {SyncCL}..." , WorkingCL ) ;
Perforce . Sync ( SyncCommandLine ) ;
Logger . LogInformation ( "Syncing to {SyncCL} has been finished." , WorkingCL ) ;
if ( ! BuildProject ( ProjectFileInfo ) )
{
IndexGenerator . Ugly ( ) ;
continue ;
}
if ( ! String . IsNullOrEmpty ( TestsToCheck ) )
{
if ( RunTests ( ) )
{
IndexGenerator . Good ( ) ;
}
else
{
IndexGenerator . Bad ( ) ;
}
}
}
Logger . LogInformation ( "Bisection complete." ) ;
Logger . LogInformation ( "Good change: {0}" , SubmittedCandidateChanges [ IndexGenerator . GoodIndex ] . CL ) ;
Logger . LogInformation ( "Bad change: {0}" , SubmittedCandidateChanges [ IndexGenerator . BadIndex ] . CL ) ;
}
catch
{
throw ;
}
finally
{
if ( ! bPerforceClientCreatedOutside )
{
P4 . DeleteClient ( PerforceClientName ) ;
}
}
}
}
}