Files
UnrealEngine/Engine/Extras/P4VUtils/Commands/PreflightCommand.cs
2025-05-18 13:04:45 +08:00

416 lines
14 KiB
C#

// Copyright Epic Games, Inc. All Rights Reserved.
using EpicGames.Perforce;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace P4VUtils.Commands
{
[Command("preflight", CommandCategory.Horde, 1)]
class PreflightCommand : Command, IDisposable
{
static public string StripReviewFyiHashTags(string InString)
{
return InString.Replace("#review", "-review", StringComparison.Ordinal).Replace("#codereview", "-codereview", StringComparison.Ordinal).Replace("#fyi", "-fyi", StringComparison.Ordinal);
}
internal int Change;
internal string? ClientName;
internal PerforceConnection? Perforce;
internal ClientRecord? Client;
internal StreamRecord? Stream;
internal DescribeRecord? DescribeRecord;
internal List<OpenedRecord>? OpenedRecords;
public override string Description => "Runs a preflight of the given changelist on Horde";
public override CustomToolInfo CustomTool => new CustomToolInfo("Horde: Preflight...", "%p");
public override async Task<int> Execute(string[] Args, IReadOnlyDictionary<string, string> ConfigValues, ILogger Logger)
{
if (!await ParseArguments(Args, ConfigValues, Logger))
{
return 1;
}
if (!await PrepareChangelist(Logger))
{
// User opted out, a clean exit.
return 0;
}
GenerateAndOpenUrl(ConfigValues, Logger);
return 0;
}
internal virtual async Task<bool> ParseArguments(string[] Args, IReadOnlyDictionary<string, string> ConfigValues, ILogger Logger)
{
if (Args.Length < 2)
{
Logger.LogError("Missing changelist number");
UserInterface.ShowSimpleDialog(
"This option requires a changelist number to be provided\r\n" +
"\r\n" +
"Please ensure the tool is properly installed and try again \r\n",
"Invalid Tool Installation?",
Logger);
return false;
}
else if (!int.TryParse(Args[1], out Change))
{
Logger.LogError("'{Argument}' is not a numbered changelist", Args[1]);
UserInterface.ShowSimpleDialog(
"This option doesn't currently support using the default changelist\r\n" +
"\r\n" +
"Please manually move the files into a non-default \r\n" +
"changelist on Perforce and try the operation again\r\n",
"This changelist requires manual fixes",
Logger);
return false;
}
ClientName = Environment.GetEnvironmentVariable("P4CLIENT");
Perforce = new PerforceConnection(null, null, ClientName, Logger);
Client = await Perforce.GetClientAsync(ClientName, CancellationToken.None);
if (Client.Stream == null)
{
Logger.LogError("Not being run from a stream client");
return false;
}
OpenedRecords = await Perforce.OpenedAsync(OpenedOptions.None, Change, null, null, -1, FileSpecList.Any, CancellationToken.None).ToListAsync();
if (OpenedRecords.Count > 0)
{
Logger.LogInformation("Shelving changelist {Change}", Change);
await Perforce.ShelveAsync(Change, ShelveOptions.Overwrite, new[] { "//..." }, CancellationToken.None);
}
List<DescribeRecord> Describe = await Perforce.DescribeAsync(DescribeOptions.Shelved, -1, new[] { Change }, CancellationToken.None);
if (Describe[0].Files.Count == 0)
{
Logger.LogError("No files are shelved in the given changelist");
return false;
}
DescribeRecord = Describe[0];
Stream = await Perforce.GetStreamAsync(Client.Stream, false, CancellationToken.None);
while (Stream.Type == "virtual" && Stream.Parent != null)
{
Stream = await Perforce.GetStreamAsync(Stream.Parent, false, CancellationToken.None);
}
// To support import+ streams get the common prefix and compare it to the Stream,
// if it doesn't match then we'll pivot to trying to find a stream that does
string CommonPrefix = DescribeRecord.Files[0].DepotFile;
if (DescribeRecord.Files.Count > 1)
{
// DescribeRecords conveniently come sorted alphabetically by depot file
// so the common prefix is the common elements of the first and last entry
string LastFile = DescribeRecord.Files.Last().DepotFile;
for (int i = 0; i < Math.Min(CommonPrefix.Length, LastFile.Length); i++)
{
if (CommonPrefix[i] != LastFile[i])
{
CommonPrefix = CommonPrefix.Substring(0, i);
break;
}
}
}
// If the common prefix is not the stream name then we'll try to find a valid stream out of the common prefix,
// but if at any point we have ambiguity we'll just fall back to the original stream again
if (!CommonPrefix.StartsWith(Stream.Name, StringComparison.Ordinal))
{
// Start by determining the common depot
int CommonStreamNameEnd = CommonPrefix.IndexOf('/', 2);
if (CommonStreamNameEnd != -1)
{
// Get the depot record and extract how many components a stream name has to determine how much
// of the common prefix to examine for a stream name
DepotRecord Depot = await Perforce.GetDepotAsync(CommonPrefix.Substring(2, CommonStreamNameEnd - 2));
int StreamDepth = Depot.GetStreamDepth();
for (int i = 0; i < StreamDepth && CommonStreamNameEnd != -1; i++)
{
CommonStreamNameEnd = CommonPrefix.IndexOf('/', CommonStreamNameEnd + 1);
}
if (CommonStreamNameEnd != -1)
{
StreamRecord? CommonStream = await Perforce.GetStreamAsync(CommonPrefix.Substring(0,CommonStreamNameEnd), false, CancellationToken.None);
if (CommonStream != null)
{
Stream = CommonStream;
}
}
}
}
return true;
}
internal virtual async Task<bool> PrepareChangelist(ILogger Logger)
{
if (CreateBackupCL())
{
// if this CL has files still open within it, create a new CL for those still opened files
// before sending the original CL to the preflight system - this avoids the problem where Horde
// cannot take ownership of the original CL and fails to checkin
if (OpenedRecords!.Count > 0)
{
InfoRecord Info = await Perforce!.GetInfoAsync(InfoOptions.None, CancellationToken.None);
ChangeRecord NewChangeRecord = new ChangeRecord();
NewChangeRecord.User = Info.UserName;
NewChangeRecord.Client = Info.ClientName;
NewChangeRecord.Description = $"{StripReviewFyiHashTags(DescribeRecord!.Description.TrimEnd())}\n#p4v-preflight-copy {Change}";
NewChangeRecord = await Perforce!.CreateChangeAsync(NewChangeRecord, CancellationToken.None);
Logger.LogInformation("Created pending changelist {Change}", NewChangeRecord.Number);
foreach (OpenedRecord OpenedRecord in OpenedRecords)
{
if (OpenedRecord.ClientFile != null)
{
await Perforce!.ReopenAsync(NewChangeRecord.Number, OpenedRecord.Type, OpenedRecord.ClientFile!, CancellationToken.None);
Logger.LogInformation("moving opened {File} to CL {CL}", OpenedRecord.ClientFile.ToString(), NewChangeRecord.Number);
}
}
}
else
{
Logger.LogInformation("No files opened, no copy CL created");
}
}
else
{
// if this CL has files still open within it, and this is a submit request - warn the user and provide options
if (OpenedRecords!.Count > 0 && IsSubmit())
{
string Prompt = "Your CL was just shelved however it still has files checked out\r\n" +
"If the files remain in the CL your preflight will fail to submit\r\n" +
"\r\n" +
"Click:\r\n" +
"[YES] - To revert local files and submit the preflight,\r\n" +
"[NO] - To start the preflight, and move the files manually\r\n" +
"[CANCEL] - To cancel the request";
string Caption = "Your CL will fail to auto-submit unless fixed";
UserInterface.Button Response = UserInterface.ShowDialog(Prompt, Caption, UserInterface.YesNoCancel, UserInterface.Button.Yes, Logger);
if (Response == UserInterface.Button.No)
{
// do nothing - user has been warned.
}
else if (Response == UserInterface.Button.Yes)
{
await Perforce!.RevertAsync(Change, null, RevertOptions.None, OpenedRecords.Select(x => x.ClientFile!).ToArray(), CancellationToken.None);
}
// any other reply is Cancel (like on Mac, hitting Escape will return a weird string, not Cancel)
else
{
Logger.LogInformation("User Opted to cancel");
return false;
}
}
}
return true;
}
internal virtual void GenerateAndOpenUrl(IReadOnlyDictionary<string, string> ConfigValues, ILogger Logger)
{
string Url = GetUrl(Stream!.Stream, Change, ConfigValues);
Logger.LogInformation("Opening {Url}", Url);
ProcessUtils.OpenInNewProcess(Url);
}
public virtual bool CreateBackupCL()
{
return false;
}
public virtual string GetUrl(string Stream, int Change, IReadOnlyDictionary<string, string> ConfigValues)
{
string BaseUrl = PreflightCommand.GetHordeServerAddress(ConfigValues);
return $"{BaseUrl}/preflight?stream={Stream}&change={Change}";
}
public virtual bool IsSubmit() { return false; }
public static string GetHordeServerAddress(IReadOnlyDictionary<string, string> ConfigValues)
{
string? BaseUrl;
if (!ConfigValues.TryGetValue("HordeServer", out BaseUrl))
{
BaseUrl = "https://configure-server-url-in-p4vutils.ini";
}
return BaseUrl.TrimEnd('/');
}
public void Dispose()
{
Perforce?.Dispose();
}
}
[Command("preflightandsubmit", CommandCategory.Horde, 2)]
class PreflightAndSubmitCommand : PreflightCommand
{
public override string Description => "Runs a preflight of the given changelist on Horde and submits it";
public override CustomToolInfo CustomTool => new CustomToolInfo("Horde: Preflight and submit", "%p");
public override string GetUrl(string Stream, int Change, IReadOnlyDictionary<string, string> ConfigValues)
{
return base.GetUrl(Stream, Change, ConfigValues) + "&defaulttemplate=true&submit=true";
}
public override bool IsSubmit() { return true; }
}
[Command("movewriteablepreflightandsubmit", CommandCategory.Horde, 3)]
class MoveWriteableFilesthenPreflightAndSubmitCommand : PreflightAndSubmitCommand
{
public override string Description => "Moves the writeable files to a new CL, then runs a preflight of the current changelist on Horde and submits it";
public override CustomToolInfo CustomTool => new CustomToolInfo("Horde: Move writeable files, Preflight and submit", "%p");
public override bool CreateBackupCL()
{
return true;
}
}
[Command("openpreflight", CommandCategory.Browser, 1)]
class OpenPreflightCommand : Command
{
public override string Description => "If the changelist has been tagged with #preflight, open the preflight Horde page in the browser";
public override CustomToolInfo CustomTool => new CustomToolInfo("Horde: Open Preflight in browser...", "$c %c");
public override async Task<int> Execute(string[] args, IReadOnlyDictionary<string, string> configValues, ILogger logger)
{
logger.LogInformation("Parsing args ...");
// Parse command lines
if (args.Length < 3)
{
logger.LogError("Not enough args for command, tool is now exiting");
return 1;
}
string clientSpec = args[1];
if (!int.TryParse(args[2], out int changeNumber))
{
logger.LogError("'{Argument}' is not a numbered changelist, tool is now exiting", args[2]);
return 1;
}
logger.LogInformation("Connecting to Perforce...");
// We prefer the native client to avoid the problem where different versions of p4.exe expect
// or return records with different formatting to each other.
PerforceSettings settings = new PerforceSettings(PerforceSettings.Default) { PreferNativeClient = true, ClientName = clientSpec };
using IPerforceConnection perforceConnection = await PerforceConnection.CreateAsync(settings, logger);
if (perforceConnection == null)
{
logger.LogError("Failed to connect to Perforce, tool is now exiting");
return 1;
}
DescribeRecord description = await perforceConnection.DescribeAsync(changeNumber);
MatchCollection matches = Regex.Matches(description.Description, @"#preflight ([0-9a-fA-F]{24})$", RegexOptions.Multiline);
if (matches.Count == 0)
{
string message = $"Description for {changeNumber} does not contain any valid preflight tags";
logger.LogInformation("{Message}", message);
UserInterface.ShowSimpleDialog(message, "No Preflights Found", logger);
return 0;
}
logger.LogInformation("Found {Count} preflight tag(s)", matches.Count);
foreach (Match? match in matches)
{
// Fairly sure that match will not be null
string preflightURL = GetUrl(match!.Groups[1].Value, configValues);
logger.LogInformation("Opening URL '{URL}' in browser", preflightURL);
ProcessUtils.OpenInNewProcess(preflightURL);
}
return 0;
}
private static string GetUrl(string preflightId, IReadOnlyDictionary<string, string> ConfigValues)
{
string BaseUrl = PreflightCommand.GetHordeServerAddress(ConfigValues);
return $"{BaseUrl}/job/{preflightId}";
}
}
[Command("preflighthordeconfig", CommandCategory.Horde, 4)]
class PreflightHordeConfigCommand : PreflightCommand
{
public override string Description => "If the changelist contains Horde configuration file(s), open Horde in the browser to validate the configuration file(s)";
public override CustomToolInfo CustomTool => new CustomToolInfo("Horde: Validate Configuration Files...", "%p");
/// <summary>
/// Is it a Horde server configuration file, ie: ends with .stream.json, .project.json, or is named globals.json
/// </summary>
/// <param name="DepotPath"></param>
/// <returns></returns>
static internal bool IsHordeConfigurationFile(string DepotPath)
{
return DepotPath.EndsWith(".json", StringComparison.OrdinalIgnoreCase);
}
internal override async Task<bool> ParseArguments(string[] Args, IReadOnlyDictionary<string, string> ConfigValues, ILogger Logger)
{
if (!await base.ParseArguments(Args, ConfigValues, Logger))
{
return false;
}
if (!DescribeRecord!.Files.Any(x => IsHordeConfigurationFile(x.DepotFile)))
{
Logger.LogError("No Horde Configuration Files");
Logger.LogError("'{Change}' does not contain Horde Configuration files.", Change);
UserInterface.ShowSimpleDialog(
$"The specified changelist, {Change}, does not contain any Horde Configuration files.",
"Invalid Changelist", Logger);
return false;
}
return true;
}
public override string GetUrl(string Stream, int Change, IReadOnlyDictionary<string, string> ConfigValues)
{
string BaseUrl = PreflightCommand.GetHordeServerAddress(ConfigValues);
return $"{BaseUrl}/preflightconfig?shelvedchange={Change}";
}
}
}