// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using Microsoft.Extensions.Logging; using System.Linq; using UnrealBuildTool; using UnrealBuildBase; 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 path. (Required)")] [Help("RootPath=", "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=", "First changelist that is in good shape. (Required)")] [Help("BadCL=", "Changelist that is known to be bad. (Required)")] [Help("Project=", "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 { /// /// The class is a helper class for bisection method. /// /// It stores a range of changelist indexes (between 'good' and 'bad' (exclusive)) to support bisection of the current range by calling Bad and Good methods. /// The class also supports Ugly method to label current changelist index with 'failed to build' mark. /// /// 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 UglyIndexes; /// /// The class constructor /// /// Length of the desired range. public Bisectomatron(int Length) { GoodIndex = 0; BadIndex = Length - 1; Jump = -1; UglyIndexes = new HashSet(); Bisect(); } /// /// The method is designed to check whether the object of the class is still valid to proceed bisection. /// /// The object to check whether it is valid. public static implicit operator bool(Bisectomatron BisectomatronInstance) { return (BisectomatronInstance.BadIndex - BisectomatronInstance.GoodIndex) > (BisectomatronInstance.UglyIndexes.Count + 1); } /// /// The method performs bisection of the current indexes range with taking into account indexes of changelists that were not built correctly. /// private void Bisect() { Index = (GoodIndex + BadIndex) / 2; while (UglyIndexes.Contains(Index)) { Index += Jump; } } /// /// The method handles current index as 'good' (build and test runs for the corresponding changelist were completed successfully) and proceed bisection if possible. /// public void Good() { UglyIndexes.RemoveWhere(UglyIndex => (UglyIndex <= Index)); GoodIndex = Index; Jump = 1; Bisect(); } /// /// The method handles current index as 'bad' (test run for the corresponding changelist was completed with error) and proceed bisection if possible. /// public void Bad() { UglyIndexes.RemoveWhere(UglyIndex => (UglyIndex >= Index)); BadIndex = Index; Jump = -1; Bisect(); } /// /// 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. /// /// 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. /// /// 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; } } } } /// /// The class is a storage class for the project file information. /// private class ProjectFileInfo { public string DepotPath { get; set; } public string FilePath { get; set; } /// /// Checks if the stored data is valid. /// /// True if the corresponding object stores valid data, otherwise - false. public bool IsValid() { return (!String.IsNullOrEmpty(DepotPath)) && (!String.IsNullOrEmpty(FilePath)); } /// /// The method returns depot path for the corresponding project file. /// /// Depot path for the corresponding project file or null if it can not be determined. 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 RequiredDirsToSync = new List{ "Engine" }; private static readonly List DirsToExcludeFromSync = new List { "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 GetRequiredDirsToSync() { List Result = new List(); foreach (string Dir in RequiredDirsToSync) { Result.Add(String.Format("{0}/{1}", TargetStream, Dir)); } return Result; } private List> GetExclusionViewParts() { List> Result = new List>(); // 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.Format("-{0}/{1}/...", TargetStream, Dir), String.Format("/{0}/...", Dir))); } } return Result; } private void GetEntriesToSync(P4Connection Perforce, int CL, ProjectFileInfo ProjectFileInfo, out List Dirs, out List 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(); 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 OverridenEnv = new Dictionary(); 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"); } /// /// Executes the command /// public override void ExecuteBuild() { ParseCommandLineArgs(); string PerforceClientName = String.Format("{0}_{1}_Automation_Bisect_Temp", P4Env.User, Unreal.MachineName); bool bPerforceClientCreatedOutside = P4.DoesClientExist(PerforceClientName); if (!bPerforceClientCreatedOutside) { List> RequiredView = new List>(); RequiredView.Add(new KeyValuePair( String.Format("{0}/...", TargetStream), String.Format("/...", PerforceClientName))); RequiredView.AddRange(GetExclusionViewParts()); P4ClientInfo PerforceClientInfo = new P4ClientInfo(); PerforceClientInfo.Owner = P4Env.User; PerforceClientInfo.Host = Unreal.MachineName; 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 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 DirsToSync; List 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); } } } } }