diff --git a/Clients/CompatApiClient/Client.cs b/Clients/CompatApiClient/Client.cs index 6697e995..99f3a12a 100644 --- a/Clients/CompatApiClient/Client.cs +++ b/Clients/CompatApiClient/Client.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Net.Http; using System.Net.Http.Json; using System.Text.Json; @@ -16,6 +17,7 @@ namespace CompatApiClient; public class Client: IDisposable { private static readonly MemoryCache ResponseCache = new(new MemoryCacheOptions { ExpirationScanFrequency = TimeSpan.FromHours(1) }); + private static readonly string[] BuildArchList = [ArchType.X64, ArchType.Arm]; private readonly HttpClient client = HttpClientFactory.Create(new CompressionMessageHandler()); private readonly JsonSerializerOptions jsonOptions = new() @@ -100,33 +102,46 @@ public class Client: IDisposable } // https://github.com/AniLeo/rpcs3-compatibility/wiki/API:-Update - public async ValueTask GetUpdateAsync(CancellationToken cancellationToken, string? commit = null) + public async ValueTask GetUpdateAsync(CancellationToken cancellationToken, string? commit = null) { - if (string.IsNullOrEmpty(commit)) + if (commit is not {Length: >6}) commit = "somecommit"; - var tries = 3; - do + var result = new UpdateInfo(); + foreach (var arch in BuildArchList) { - try + var tries = 3; + do { - using var message = new HttpRequestMessage(HttpMethod.Get, "https://update.rpcs3.net/?api=v1&c=" + commit); - using var response = await client.SendAsync(message, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false); try { - return await response.Content.ReadFromJsonAsync(jsonOptions, cancellationToken).ConfigureAwait(false); + using var message = new HttpRequestMessage( + HttpMethod.Get, + $"https://update.rpcs3.net/?api=v3&os_arch={arch}&os_type=all&c={commit}" + ); + using var response = await client + .SendAsync(message, HttpCompletionOption.ResponseContentRead, cancellationToken) + .ConfigureAwait(false); + try + { + var info = await response.Content + .ReadFromJsonAsync(jsonOptions, cancellationToken) + .ConfigureAwait(false); + if (info is not null) + result[arch] = info; + } + catch (Exception e) + { + ConsoleLogger.PrintError(e, response, false); + } } catch (Exception e) { - ConsoleLogger.PrintError(e, response, false); + ApiConfig.Log.Warn(e); } - } - catch (Exception e) - { - ApiConfig.Log.Warn(e); - } - tries++; - } while (tries < 3); - return null; + tries++; + } while (tries < 3); + } + return result; } public void Dispose() diff --git a/Clients/CompatApiClient/POCOs/UpdateCheckResult.cs b/Clients/CompatApiClient/POCOs/UpdateCheckResult.cs new file mode 100644 index 00000000..ca5bb17b --- /dev/null +++ b/Clients/CompatApiClient/POCOs/UpdateCheckResult.cs @@ -0,0 +1,47 @@ +namespace CompatApiClient.POCOs; + +public class UpdateCheckResult +{ + public StatusCode ReturnCode; + public BuildInfo LatestBuild = null!; + public BuildInfo? CurrentBuild; + public VersionInfo[]? Changelog; +} + +public class BuildInfo +{ + public int? Pr; + public string Datetime = null!; + public string Version = null!; + public BuildLink? Windows; + public BuildLink? Linux; + public BuildLink? Mac; +} + +public class BuildLink +{ + public string Download = null!; + public int? Size; + public string? Checksum; +} + +public class VersionInfo +{ + public string Verison = null!; + public string? Title; +} + +public enum StatusCode +{ + IllegalSearch = -3, + Maintenance = -2, + UnknownBuild = -1, + NoUpdates = 0, + UpdatesAvailable = 1, +} + +public static class ArchType +{ + public const string X64 = "x64"; + public const string Arm = "arm64"; +} diff --git a/Clients/CompatApiClient/POCOs/UpdateInfo.cs b/Clients/CompatApiClient/POCOs/UpdateInfo.cs deleted file mode 100644 index 16c8508e..00000000 --- a/Clients/CompatApiClient/POCOs/UpdateInfo.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace CompatApiClient.POCOs; -#nullable disable - -public class UpdateInfo -{ - public int ReturnCode; - public BuildInfo LatestBuild; - public BuildInfo CurrentBuild; -} - -public class BuildInfo -{ - public int? Pr; - public string Datetime; - public BuildLink Windows; - public BuildLink Linux; - public BuildLink Mac; -} - -public class BuildLink -{ - public string Download; - public int? Size; - public string Checksum; -} - -#nullable restore \ No newline at end of file diff --git a/Clients/CompatApiClient/UpdateInfo.cs b/Clients/CompatApiClient/UpdateInfo.cs new file mode 100644 index 00000000..93585264 --- /dev/null +++ b/Clients/CompatApiClient/UpdateInfo.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using CompatApiClient.POCOs; + +namespace CompatApiClient; + +public class UpdateInfo +{ + public UpdateCheckResult? X64; + public UpdateCheckResult? Arm; + + public UpdateCheckResult? this[string key] + { + get => key switch + { + ArchType.X64 => X64, + ArchType.Arm => Arm, + _ => throw new KeyNotFoundException($"Unknown {nameof(ArchType)} '{key}'") + }; + set + { + if (key is ArchType.X64) + X64 = value; + else if (key is ArchType.Arm) + Arm = value; + else + throw new KeyNotFoundException($"Unknown {nameof(ArchType)} '{key}'"); + } + } + + public void SetCurrentAsLatest() + { + if (this is {X64.CurrentBuild: not null, Arm.CurrentBuild: not null}) + { + X64.LatestBuild = X64.CurrentBuild; + X64.CurrentBuild = null; + Arm.LatestBuild = Arm.CurrentBuild; + Arm.CurrentBuild = null; + } + else if (X64?.CurrentBuild is not null) + { + X64.LatestBuild = X64.CurrentBuild; + X64.CurrentBuild = null; + Arm = null; + } + else if (Arm?.CurrentBuild is not null) + { + Arm.LatestBuild = Arm.CurrentBuild; + Arm.CurrentBuild = null; + X64 = null; + } + } + + public StatusCode ReturnCode => (X64?.ReturnCode, Arm?.ReturnCode) switch + { + ({ } v1, { } v2) when v1 == v2 => v1, + ({ } v1 and >= StatusCode.UnknownBuild, { } v2 and >= StatusCode.UnknownBuild) => (StatusCode)Math.Max((int)v1, + (int)v2), + ({ }, { }) => StatusCode.Maintenance, + ({ } v, null) => v, + (null, { } v) => v, + _ => StatusCode.Maintenance, + }; + + public DateTime? LatestDatetime => ((X64?.LatestBuild.Datetime, Arm?.LatestBuild.Datetime) switch + { + ({ Length: > 0 } d1, { Length: > 0 } d2) => StringComparer.Ordinal.Compare(d1, d1) >= 0 ? d1 : d2, + ({ Length: > 0 } d, _) => d, + (_, { Length: > 0 } d) => d, + _ => null, + }) switch + { + { Length: > 0 } v when DateTime.TryParse(v, out var result) => result, + _ => null, + }; + + public DateTime? CurrentDatetime => ((X64?.CurrentBuild?.Datetime, Arm?.CurrentBuild?.Datetime) switch + { + ({ Length: > 0 } d1, { Length: > 0 } d2) => StringComparer.Ordinal.Compare(d1, d1) >= 0 ? d1 : d2, + ({ Length: > 0 } d, _) => d, + (_, { Length: > 0 } d) => d, + _ => null, + }) switch + { + { Length: > 0 } v when DateTime.TryParse(v, out var result) => result, + _ => null, + }; + + public int? LatestPr => (X64?.LatestBuild.Pr, Arm?.LatestBuild.Pr) switch + { + //(int pr1, int pr2) when pr1 != pr2 => throw new InvalidDataException($"Expected the same PR for both {nameof(ArchType)}, but got {pr1} and {pr2}"), + (int pr, _) => pr, + (_, int pr) => pr, + _ => null, + }; +} \ No newline at end of file diff --git a/Clients/GithubClient/Client.CI.cs b/Clients/GithubClient/Client.CI.cs index 9edfcaab..9bc4aaca 100644 --- a/Clients/GithubClient/Client.CI.cs +++ b/Clients/GithubClient/Client.CI.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; @@ -90,7 +91,6 @@ public partial class Client ExcludePullRequests = false, Event = "pull_request", HeadSha = commit, - //Branch = $"refs/pull/{pr}/merge", }; var runsList = await client.Actions.Workflows.Runs.ListByWorkflow(OwnerId, RepoId, wfId, wfrRequest).ConfigureAwait(false); var builds = runsList.WorkflowRuns @@ -299,4 +299,72 @@ public partial class Client BuildInfoCache.Set(cacheKey, result, TimeSpan.FromDays(1)); return result; } + + public async ValueTask?> GetMasterBuildsAsync(string? oldestMergeCommit, string? newestMergeCommit, DateTime? oldestTimestamp, CancellationToken cancellationToken) + { + if (oldestMergeCommit is not {Length: >=6} || newestMergeCommit is not {Length: >=6}) + return null; + + if (await GetWorkflowIdAsync().ConfigureAwait(false) is not long wfId) + return null; + + oldestMergeCommit = oldestMergeCommit.ToLower(); + newestMergeCommit = newestMergeCommit.ToLower(); + var wfrRequest = new WorkflowRunsRequest + { + ExcludePullRequests = true, + Event = "push", + Created = $"{oldestTimestamp:yyyy-MM-dd}..*", + Status = CheckRunStatusFilter.Completed, + Branch = "master", + }; + var runsList = await client.Actions.Workflows.Runs.ListByWorkflow(OwnerId, RepoId, wfId, wfrRequest).ConfigureAwait(false); + var builds = runsList.WorkflowRuns + .OrderByDescending(r => r.CreatedAt) + .SkipWhile(b => !newestMergeCommit.Equals(b.HeadSha, StringComparison.OrdinalIgnoreCase)) + .Skip(1) + .TakeWhile(b => !oldestMergeCommit.Equals(b.HeadSha, StringComparison.OrdinalIgnoreCase)) + .ToList(); + return await builds + .ToAsyncEnumerable() + .SelectAwait(async b => await GetArtifactsInfoAsync(b.HeadSha, b, cancellationToken).ConfigureAwait(false)) + .ToListAsync(cancellationToken: cancellationToken) + .ConfigureAwait(false); + } + + public async ValueTask GetMasterBuildInfoAsync(string? commit, DateTime? oldestTimestamp, CancellationToken cancellationToken) + { + if (commit is not {Length: >=6}) + return null; + + if (await GetWorkflowIdAsync().ConfigureAwait(false) is not long wfId) + return null; + + commit = commit.ToLower(); + if (BuildInfoCache.TryGetValue(commit, out BuildInfo? result) && result is not null) + return result; + + var wfrRequest = new WorkflowRunsRequest + { + ExcludePullRequests = true, + Event = "push", + Created = $"{oldestTimestamp:yyyy-MM-dd}..*", + Status = CheckRunStatusFilter.Completed, + Branch = "master", + }; + var runsList = await client.Actions.Workflows.Runs.ListByWorkflow(OwnerId, RepoId, wfId, wfrRequest).ConfigureAwait(false); + + var builds = runsList.WorkflowRuns + .Where(b => commit.Equals(b.HeadSha, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(b => b.CreatedAt) + .ToList(); + if (builds.FirstOrDefault() is not {} latestBuild) + return null; + + result = await GetArtifactsInfoAsync(commit, latestBuild, cancellationToken).ConfigureAwait(false); + if (result is { Status: WorkflowRunStatus.Completed, Result: WorkflowRunConclusion.Success }) + BuildInfoCache.Set(commit, result, TimeSpan.FromHours(1)); + return result; + } + } \ No newline at end of file diff --git a/Clients/GithubClient/GithubClient.csproj b/Clients/GithubClient/GithubClient.csproj index 8a91f30e..a79469aa 100644 --- a/Clients/GithubClient/GithubClient.csproj +++ b/Clients/GithubClient/GithubClient.csproj @@ -8,6 +8,7 @@ + diff --git a/CompatBot/Commands/CompatList.Latest.cs b/CompatBot/Commands/CompatList.Latest.cs index 23a30d9f..13cd6ad3 100644 --- a/CompatBot/Commands/CompatList.Latest.cs +++ b/CompatBot/Commands/CompatList.Latest.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using CompatApiClient; using CompatApiClient.POCOs; using CompatBot.Database; using CompatBot.EventHandlers; @@ -7,6 +8,7 @@ using CompatBot.Utils.ResultFormatters; using DSharpPlus.Commands.Processors.TextCommands; using Microsoft.EntityFrameworkCore; using Microsoft.TeamFoundation.Build.WebApi; +using Octokit; namespace CompatBot.Commands; @@ -19,12 +21,12 @@ internal static partial class CompatList [Description("Link to the latest RPCS3 build")] public static ValueTask Latest(SlashCommandContext ctx) => CheckForRpcs3UpdatesAsync(ctx, respond: true); - /* +#if DEBUG [Command("since")] [Description("Show additional info about changes since specified update")] - public static ValueTask Since(TextCommandContext ctx, [Description("Commit hash of the update, such as `46abe0f31`")] string commit) + public static ValueTask Since(SlashCommandContext ctx, [Description("Commit hash of the update, such as `46abe0f31`")] string commit) => CheckForRpcs3UpdatesAsync(ctx, respond: true, sinceCommit: commit); - */ +#endif [Command("clear"), RequiresBotModRole] [Description("Clear the update info cache and post the latest RPCS3 build announcement")] @@ -69,13 +71,13 @@ internal static partial class CompatList var updateAnnouncementRestore = emptyBotMsg is not null; var info = await Client.GetUpdateAsync(Config.Cts.Token, sinceCommit).ConfigureAwait(false); - if (info?.ReturnCode != 1 && sinceCommit != null) + if (info.ReturnCode != StatusCode.UpdatesAvailable && sinceCommit is not null) info = await Client.GetUpdateAsync(Config.Cts.Token).ConfigureAwait(false); - if (updateAnnouncementRestore && info?.CurrentBuild != null) - info.LatestBuild = info.CurrentBuild; + if (updateAnnouncementRestore) + info.SetCurrentAsLatest(); var embed = await info.AsEmbedAsync(discordClient, !respond).ConfigureAwait(false); - if (info is null || embed.Color!.Value.Value == Config.Colors.Maintenance.Value) + if (info.ReturnCode < StatusCode.UnknownBuild || embed.Color?.Value == Config.Colors.Maintenance.Value) { if (updateAnnouncementRestore) { @@ -87,10 +89,8 @@ internal static partial class CompatList } else if (!updateAnnouncementRestore) { - if (cachedUpdateInfo?.LatestBuild?.Datetime is string previousBuildTimeStr - && info.LatestBuild?.Datetime is string newBuildTimeStr - && DateTime.TryParse(previousBuildTimeStr, out var previousBuildTime) - && DateTime.TryParse(newBuildTimeStr, out var newBuildTime) + if (cachedUpdateInfo?.LatestDatetime is DateTime previousBuildTime + && info.LatestDatetime is DateTime newBuildTime && newBuildTime > previousBuildTime) cachedUpdateInfo = info; } @@ -113,7 +113,7 @@ internal static partial class CompatList return; } - var latestUpdatePr = info?.LatestBuild?.Pr?.ToString(); + var latestUpdatePr = info.LatestPr?.ToString(); var match = ( from field in embed.Fields let m = UpdateVersionRegex().Match(field.Value) @@ -122,15 +122,15 @@ internal static partial class CompatList ).FirstOrDefault(); var latestUpdateBuild = match?.Groups["build"].Value; - if (string.IsNullOrEmpty(latestUpdatePr) + if (latestUpdatePr is not {Length: >0} || lastUpdateInfo == latestUpdatePr || !await UpdateCheck.WaitAsync(0).ConfigureAwait(false)) return; try { - if (!string.IsNullOrEmpty(lastFullBuildNumber) - && !string.IsNullOrEmpty(latestUpdateBuild) + if (lastFullBuildNumber is {Length: >0} + && latestUpdateBuild is {Length: >0} && int.TryParse(lastFullBuildNumber, out var lastSaveBuild) && int.TryParse(latestUpdateBuild, out var latestBuild) && latestBuild <= lastSaveBuild) @@ -147,7 +147,7 @@ internal static partial class CompatList return; } - if (embed.Color!.Value.Value == Config.Colors.Maintenance.Value) + if (embed.Color?.Value == Config.Colors.Maintenance.Value) return; await CheckMissedBuildsBetweenAsync(discordClient, compatChannel, lastUpdateInfo, latestUpdatePr, Config.Cts.Token).ConfigureAwait(false); @@ -157,18 +157,14 @@ internal static partial class CompatList lastFullBuildNumber = latestUpdateBuild; await using (var wdb = await BotDb.OpenWriteAsync().ConfigureAwait(false)) { - var currentState = await wdb.BotState.FirstOrDefaultAsync(k => k.Key == Rpcs3UpdateStateKey) - .ConfigureAwait(false); + var currentState = await wdb.BotState.FirstOrDefaultAsync(k => k.Key == Rpcs3UpdateStateKey).ConfigureAwait(false); if (currentState == null) - await wdb.BotState.AddAsync(new() { Key = Rpcs3UpdateStateKey, Value = latestUpdatePr }) - .ConfigureAwait(false); + await wdb.BotState.AddAsync(new() { Key = Rpcs3UpdateStateKey, Value = latestUpdatePr }).ConfigureAwait(false); else currentState.Value = latestUpdatePr; - var savedLastBuild = await wdb.BotState.FirstOrDefaultAsync(k => k.Key == Rpcs3UpdateBuildKey) - .ConfigureAwait(false); + var savedLastBuild = await wdb.BotState.FirstOrDefaultAsync(k => k.Key == Rpcs3UpdateBuildKey).ConfigureAwait(false); if (savedLastBuild == null) - await wdb.BotState.AddAsync(new() { Key = Rpcs3UpdateBuildKey, Value = latestUpdateBuild }) - .ConfigureAwait(false); + await wdb.BotState.AddAsync(new() { Key = Rpcs3UpdateBuildKey, Value = latestUpdateBuild }).ConfigureAwait(false); else savedLastBuild.Value = latestUpdateBuild; await wdb.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); @@ -194,7 +190,7 @@ internal static partial class CompatList var mergedPrs = await GithubClient.GetClosedPrsAsync(cancellationToken).ConfigureAwait(false); // this will cache 30 latest PRs var newestPrCommit = await GithubClient.GetPrInfoAsync(newestPr, cancellationToken).ConfigureAwait(false); var oldestPrCommit = await GithubClient.GetPrInfoAsync(oldestPr, cancellationToken).ConfigureAwait(false); - if (newestPrCommit?.MergedAt == null || oldestPrCommit?.MergedAt == null) + if (newestPrCommit?.MergedAt is null || oldestPrCommit?.MergedAt is null) return; mergedPrs = mergedPrs?.Where(pri => pri.MergedAt.HasValue) @@ -203,10 +199,16 @@ internal static partial class CompatList .Skip(1) .TakeWhile(pri => pri.Number != newestPr) .ToList(); - if (mergedPrs is null or {Count: 0}) + if (mergedPrs is not {Count: >0}) return; - var failedBuilds = await Config.GetAzureDevOpsClient().GetMasterBuildsAsync( + var failedAzureBuilds = await Config.GetAzureDevOpsClient().GetMasterBuildsAsync( + oldestPrCommit.MergeCommitSha, + newestPrCommit.MergeCommitSha, + oldestPrCommit.MergedAt?.DateTime, + cancellationToken + ).ConfigureAwait(false); + var failedGhBuilds = await GithubClient.GetMasterBuildsAsync( oldestPrCommit.MergeCommitSha, newestPrCommit.MergeCommitSha, oldestPrCommit.MergedAt?.DateTime, @@ -214,31 +216,62 @@ internal static partial class CompatList ).ConfigureAwait(false); foreach (var mergedPr in mergedPrs) { - var updateInfo = await Client.GetUpdateAsync(cancellationToken, mergedPr.MergeCommitSha).ConfigureAwait(false) - ?? new UpdateInfo {ReturnCode = -1}; - if (updateInfo.ReturnCode is 0 or 1) // latest or known build + var updateInfo = await Client.GetUpdateAsync(cancellationToken, mergedPr.MergeCommitSha).ConfigureAwait(false); + if (updateInfo.ReturnCode >= StatusCode.NoUpdates) // latest or known build { - updateInfo.LatestBuild = updateInfo.CurrentBuild; - updateInfo.CurrentBuild = null; + if (updateInfo is { X64.CurrentBuild: not null, Arm.CurrentBuild: not null }) + { + updateInfo.X64.LatestBuild = updateInfo.X64.CurrentBuild; + updateInfo.X64.CurrentBuild = null; + updateInfo.Arm.LatestBuild = updateInfo.Arm.CurrentBuild; + updateInfo.Arm.CurrentBuild = null; + } + else if (updateInfo.X64?.CurrentBuild is not null) + { + updateInfo.X64.LatestBuild = updateInfo.X64.CurrentBuild; + updateInfo.X64.CurrentBuild = null; + updateInfo.Arm = null; + } + else if (updateInfo.Arm?.CurrentBuild is not null) + { + updateInfo.Arm.LatestBuild = updateInfo.Arm.CurrentBuild; + updateInfo.Arm.CurrentBuild = null; + updateInfo.X64 = null; + } var embed = await updateInfo.AsEmbedAsync(discordClient, true).ConfigureAwait(false); await compatChannel.SendMessageAsync(embed: embed.Build()).ConfigureAwait(false); } - else if (updateInfo.ReturnCode == -1) // unknown build + else if (updateInfo.ReturnCode is StatusCode.UnknownBuild) { - var masterBuildInfo = failedBuilds?.FirstOrDefault(b => b.Commit?.Equals(mergedPr.MergeCommitSha, StringComparison.InvariantCultureIgnoreCase) is true); - var buildTime = masterBuildInfo?.FinishTime; - if (masterBuildInfo != null) + var masterBuildInfoAzure = failedAzureBuilds?.FirstOrDefault(b => b.Commit?.Equals(mergedPr.MergeCommitSha, StringComparison.OrdinalIgnoreCase) is true); + var masterBuildInfoGh = failedGhBuilds?.FirstOrDefault(b => b.Commit?.Equals(mergedPr.MergeCommitSha, StringComparison.OrdinalIgnoreCase) is true); + var buildTime = masterBuildInfoGh?.FinishTime ?? masterBuildInfoAzure?.FinishTime; + if (masterBuildInfoAzure is not null || masterBuildInfoGh is not null) { updateInfo = new() { - ReturnCode = 1, - LatestBuild = new() + X64 = new() { - Datetime = buildTime?.ToString("yyyy-MM-dd HH:mm:ss"), - Pr = mergedPr.Number, - Windows = new() {Download = masterBuildInfo.WindowsBuildDownloadLink ?? ""}, - Linux = new() { Download = masterBuildInfo.LinuxBuildDownloadLink ?? "" }, - Mac = new() { Download = masterBuildInfo.MacBuildDownloadLink ?? "" }, + ReturnCode = StatusCode.UpdatesAvailable, + LatestBuild = new() + { + Datetime = buildTime!.Value.ToString("yyyy-MM-dd HH:mm:ss"), + Pr = mergedPr.Number, + Windows = new() { Download = masterBuildInfoAzure?.WindowsBuildDownloadLink ?? masterBuildInfoGh?.WindowsBuildDownloadLink ?? "" }, + Linux = new() { Download = masterBuildInfoAzure?.LinuxBuildDownloadLink ?? masterBuildInfoGh?.LinuxBuildDownloadLink ?? "" }, + Mac = new() { Download = masterBuildInfoAzure?.MacBuildDownloadLink ?? masterBuildInfoGh?.MacBuildDownloadLink ?? "" }, + }, + }, + Arm = new() + { + ReturnCode = StatusCode.UpdatesAvailable, + LatestBuild = new() + { + Datetime = buildTime!.Value.ToString("yyyy-MM-dd HH:mm:ss"), + Pr = mergedPr.Number, + Linux = new() { Download = masterBuildInfoAzure?.LinuxArmBuildDownloadLink ?? masterBuildInfoGh?.LinuxArmBuildDownloadLink ?? "" }, + Mac = new() { Download = masterBuildInfoAzure?.MacArmBuildDownloadLink ?? masterBuildInfoGh?.MacArmBuildDownloadLink ?? "" }, + }, }, }; } @@ -246,20 +279,27 @@ internal static partial class CompatList { updateInfo = new() { - ReturnCode = 1, - LatestBuild = new() + X64 = new() { - Pr = mergedPr.Number, - Windows = new() {Download = ""}, - Linux = new() { Download = "" }, - Mac = new() { Download = "" }, + ReturnCode = StatusCode.UpdatesAvailable, + LatestBuild = new() + { + Pr = mergedPr.Number, + }, }, }; } var embed = await updateInfo.AsEmbedAsync(discordClient, true).ConfigureAwait(false); embed.Color = Config.Colors.PrClosed; embed.ClearFields(); - var reason = masterBuildInfo?.Result switch + var reason = masterBuildInfoGh?.Result switch + { + WorkflowRunConclusion.Success => "Built", + WorkflowRunConclusion.Failure => "Failed to build", + WorkflowRunConclusion.Cancelled => "Cancelled", + WorkflowRunConclusion.TimedOut => "Timed out", + _ => null, + } ?? masterBuildInfoAzure?.Result switch { BuildResult.Succeeded => "Built", BuildResult.PartiallySucceeded => "Built", @@ -267,7 +307,7 @@ internal static partial class CompatList BuildResult.Canceled => "Cancelled", _ => null, }; - if (buildTime.HasValue && reason != null) + if (buildTime.HasValue && reason is not null) embed.WithFooter($"{reason} on {buildTime:u} ({(DateTime.UtcNow - buildTime.Value).AsTimeDeltaDescription()} ago)"); else embed.WithFooter(reason ?? "Never built"); diff --git a/CompatBot/Commands/CompatList.cs b/CompatBot/Commands/CompatList.cs index a44b277f..7c6ae1f7 100644 --- a/CompatBot/Commands/CompatList.cs +++ b/CompatBot/Commands/CompatList.cs @@ -33,9 +33,11 @@ internal static partial class CompatList static CompatList() { - using var db = BotDb.OpenRead(); - lastUpdateInfo = db.BotState.FirstOrDefault(k => k.Key == Rpcs3UpdateStateKey)?.Value; - lastFullBuildNumber = db.BotState.FirstOrDefault(k => k.Key == Rpcs3UpdateBuildKey)?.Value; + using (var db = BotDb.OpenRead()) + { + lastUpdateInfo = db.BotState.FirstOrDefault(k => k.Key == Rpcs3UpdateStateKey)?.Value; + lastFullBuildNumber = db.BotState.FirstOrDefault(k => k.Key == Rpcs3UpdateBuildKey)?.Value; + } //lastUpdateInfo = "8022"; if (lastUpdateInfo is {Length: >0} strPr && int.TryParse(strPr, out var pr)) { @@ -43,11 +45,10 @@ internal static partial class CompatList { var prInfo = GithubClient.GetPrInfoAsync(pr, Config.Cts.Token).ConfigureAwait(false).GetAwaiter().GetResult(); cachedUpdateInfo = Client.GetUpdateAsync(Config.Cts.Token, prInfo?.MergeCommitSha).ConfigureAwait(false).GetAwaiter().GetResult(); - if (cachedUpdateInfo?.CurrentBuild == null) + if ((cachedUpdateInfo.X64 ?? cachedUpdateInfo.Arm)?.CurrentBuild is null) return; - - cachedUpdateInfo.LatestBuild = cachedUpdateInfo.CurrentBuild; - cachedUpdateInfo.CurrentBuild = null; + + cachedUpdateInfo.SetCurrentAsLatest(); } catch { } } diff --git a/CompatBot/Commands/ContentFilters.cs b/CompatBot/Commands/ContentFilters.cs index ad6f35c6..28faa0c4 100644 --- a/CompatBot/Commands/ContentFilters.cs +++ b/CompatBot/Commands/ContentFilters.cs @@ -135,13 +135,13 @@ internal sealed partial class ContentFilters if (explanation is { Length: > 0 } && !await wdb.Explanation.AnyAsync(e => e.Keyword == explanation).ConfigureAwait(false)) { - await ctx.RespondAsync($"❌ Unknown explanation term: {explanation}", ephemeral: ephemeral) - .ConfigureAwait(false); + await ctx.RespondAsync($"❌ Unknown explanation term: {explanation}", ephemeral: ephemeral).ConfigureAwait(false); return; } var isNewFilter = true; - var filter = await wdb.Piracystring.FirstOrDefaultAsync(ps => ps.String == trigger && ps.Disabled) + var filter = await wdb.Piracystring + .FirstOrDefaultAsync(ps => ps.String == trigger && ps.Disabled) .ConfigureAwait(false); if (filter is null) filter = new() { String = trigger }; @@ -184,8 +184,7 @@ internal sealed partial class ContentFilters $"{member?.GetMentionWithNickname()} added a new content filter: `{filter.String.Sanitize()}`"; if (!string.IsNullOrEmpty(filter.ValidatingRegex)) reportMsg += $"\nValidation: `{filter.ValidatingRegex}`"; - await ctx.Client.ReportAsync("🆕 Content filter created", reportMsg, null, ReportSeverity.Low) - .ConfigureAwait(false); + await ctx.Client.ReportAsync("🆕 Content filter created", reportMsg, null, ReportSeverity.Low).ConfigureAwait(false); } ContentFilter.RebuildMatcher(); } @@ -327,8 +326,7 @@ internal sealed partial class ContentFilters if (explanation is { Length: > 0 } && !await wdb.Explanation.AnyAsync(e => e.Keyword == explanation).ConfigureAwait(false)) { - await ctx.RespondAsync($"❌ Unknown explanation term: {explanation}", ephemeral: ephemeral) - .ConfigureAwait(false); + await ctx.RespondAsync($"❌ Unknown explanation term: {explanation}", ephemeral: ephemeral).ConfigureAwait(false); return; } diff --git a/CompatBot/Commands/Misc.cs b/CompatBot/Commands/Misc.cs index 9bf07e14..947d8d75 100644 --- a/CompatBot/Commands/Misc.cs +++ b/CompatBot/Commands/Misc.cs @@ -249,8 +249,7 @@ internal static partial class Misc var count = await db.Thumbnail.CountAsync().ConfigureAwait(false); if (count is 0) { - await ctx.RespondAsync("Sorry, I have no information about a single game yet", ephemeral: true) - .ConfigureAwait(false); + await ctx.RespondAsync("Sorry, I have no information about a single game yet", ephemeral: true).ConfigureAwait(false); return; } @@ -265,8 +264,7 @@ internal static partial class Misc return; } - var result = await ProductCodeLookup.LookupProductCodeAndFormatAsync(ctx.Client, [productCode.ProductCode]) - .ConfigureAwait(false); + var result = await ProductCodeLookup.LookupProductCodeAndFormatAsync(ctx.Client, [productCode.ProductCode]).ConfigureAwait(false); await ctx.RespondAsync(result[0].builder, ephemeral: ephemeral).ConfigureAwait(false); } } diff --git a/CompatBot/Commands/Pr.cs b/CompatBot/Commands/Pr.cs index 87a15ab2..eca8b301 100644 --- a/CompatBot/Commands/Pr.cs +++ b/CompatBot/Commands/Pr.cs @@ -365,21 +365,21 @@ internal sealed class Pr var mergeTime = prInfo.MergedAt.GetValueOrDefault(); var now = DateTime.UtcNow; var updateInfo = await CompatApiClient.GetUpdateAsync(Config.Cts.Token, linkOld ? prInfo.MergeCommitSha : null).ConfigureAwait(false); - if (updateInfo is not null) + if (updateInfo.LatestDatetime is DateTime masterBuildTime && masterBuildTime.Ticks >= mergeTime.Ticks) + embed = await updateInfo.AsEmbedAsync(client, false, embed, prInfo, linkOld).ConfigureAwait(false); + else { - if (DateTime.TryParse(updateInfo.LatestBuild?.Datetime, out var masterBuildTime) && masterBuildTime.Ticks >= mergeTime.Ticks) - embed = await updateInfo.AsEmbedAsync(client, false, embed, prInfo, linkOld).ConfigureAwait(false); - else - { - var waitTime = TimeSpan.FromMinutes(5); - var avgBuildTime = (await GithubClient.GetPipelineDurationAsync(Config.Cts.Token).ConfigureAwait(false)).Mean; - if (now < mergeTime + avgBuildTime) - waitTime = mergeTime + avgBuildTime - now; - embed.AddField("Latest master build", $""" - This pull request has been merged, and will be part of `master` very soon. - Please check again in {waitTime.AsTimeDeltaDescription()}. - """); - } + var waitTime = TimeSpan.FromMinutes(5); + var avgBuildTime = (await GithubClient.GetPipelineDurationAsync(Config.Cts.Token).ConfigureAwait(false)).Mean; + if (now < mergeTime + avgBuildTime) + waitTime = mergeTime + avgBuildTime - now; + embed.AddField( + "Latest master build", + $""" + This pull request has been merged, and will be part of `master` very soon. + Please check again in {waitTime.AsTimeDeltaDescription()}. + """ + ); } } return result.AddEmbed(embed); diff --git a/CompatBot/EventHandlers/LogParsingHandler.cs b/CompatBot/EventHandlers/LogParsingHandler.cs index dd60feaf..0747fce6 100644 --- a/CompatBot/EventHandlers/LogParsingHandler.cs +++ b/CompatBot/EventHandlers/LogParsingHandler.cs @@ -112,8 +112,7 @@ public static class LogParsingHandler $">>>>>>> {message.Id % 100} Parsing log '{source.FileName}' from {message.Author.Username}#{message.Author.Discriminator} ({message.Author.Id}) using {source.GetType().Name} ({source.SourceFileSize} bytes)…"); var analyzingProgressEmbed = GetAnalyzingMsgEmbed(client); var msgBuilder = new DiscordMessageBuilder() - .AddEmbed(await analyzingProgressEmbed.AddAuthorAsync(client, message, source) - .ConfigureAwait(false)) + .AddEmbed(await analyzingProgressEmbed.AddAuthorAsync(client, message, source).ConfigureAwait(false)) .WithReply(message.Id); botMsg = await channel.SendMessageAsync(msgBuilder).ConfigureAwait(false); parsedLog = true; @@ -130,8 +129,7 @@ public static class LogParsingHandler source, async () => botMsg = await botMsg.UpdateOrCreateMessageAsync( channel, - embed: await analyzingProgressEmbed.AddAuthorAsync(client, message, source) - .ConfigureAwait(false) + embed: await analyzingProgressEmbed.AddAuthorAsync(client, message, source).ConfigureAwait(false) ).ConfigureAwait(false), combinedTokenSource.Token ).ConfigureAwait(false); @@ -175,15 +173,12 @@ public static class LogParsingHandler } var yarr = client.GetEmoji(":piratethink:", "☠"); result.ReadBytes = 0; - if (await message.Author.IsWhitelistedAsync(client, channel.Guild) - .ConfigureAwait(false)) + if (await message.Author.IsWhitelistedAsync(client, channel.Guild).ConfigureAwait(false)) { - var piracyWarning = await result.AsEmbedAsync(client, message, source) - .ConfigureAwait(false); + var piracyWarning = await result.AsEmbedAsync(client, message, source).ConfigureAwait(false); piracyWarning = piracyWarning.WithDescription( "Please remove the log and issue warning to the original author of the log"); - botMsg = await botMsg.UpdateOrCreateMessageAsync(channel, embed: piracyWarning) - .ConfigureAwait(false); + botMsg = await botMsg.UpdateOrCreateMessageAsync(channel, embed: piracyWarning).ConfigureAwait(false); var matchedOn = ContentFilter.GetMatchedScope(result.SelectedFilter, result.SelectedFilterContext); await client.ReportAsync(yarr + " Pirated Release (whitelisted by role)", message, @@ -269,19 +264,16 @@ public static class LogParsingHandler if (!force && string.IsNullOrEmpty(message.Content) && !isSpamChannel - && !await message.Author.IsSmartlistedAsync(client, message.Channel.Guild) - .ConfigureAwait(false)) + && !await message.Author.IsSmartlistedAsync(client, message.Channel.Guild).ConfigureAwait(false)) { var threshold = DateTime.UtcNow.AddMinutes(-15); - var previousMessages = await channel.GetMessagesBeforeCachedAsync(message.Id) - .ConfigureAwait(false); + var previousMessages = await channel.GetMessagesBeforeCachedAsync(message.Id).ConfigureAwait(false); previousMessages = previousMessages.TakeWhile((msg, num) => num < 15 || msg.Timestamp.UtcDateTime > threshold).ToList(); if (!previousMessages.Any(m => m.Author == message.Author && !string.IsNullOrEmpty(m.Content))) { - var botSpamChannel = await client.GetChannelAsync(Config.BotSpamId) - .ConfigureAwait(false); + var botSpamChannel = await client.GetChannelAsync(Config.BotSpamId).ConfigureAwait(false); if (isHelpChannel) await botMsg.UpdateOrCreateMessageAsync( channel, diff --git a/CompatBot/Utils/ResultFormatters/LogParserResultFormatter.GeneralNotesSection.cs b/CompatBot/Utils/ResultFormatters/LogParserResultFormatter.GeneralNotesSection.cs index 4335347e..c993cd33 100644 --- a/CompatBot/Utils/ResultFormatters/LogParserResultFormatter.GeneralNotesSection.cs +++ b/CompatBot/Utils/ResultFormatters/LogParserResultFormatter.GeneralNotesSection.cs @@ -411,9 +411,10 @@ internal static partial class LogParserResult var updateInfo = await CheckForUpdateAsync(items).ConfigureAwait(false); var buildBranch = items["build_branch"]?.ToLowerInvariant(); - if (updateInfo != null + if (updateInfo is not null && (buildBranch is "master" or "head" or "spu_perf" - || string.IsNullOrEmpty(buildBranch) && updateInfo.CurrentBuild != null)) + || buildBranch is not {Length: >0} + && (updateInfo.X64?.CurrentBuild is not null || updateInfo.Arm?.CurrentBuild is not null))) { string prefix = "⚠️"; string timeDeltaStr; diff --git a/CompatBot/Utils/ResultFormatters/LogParserResultFormatter.cs b/CompatBot/Utils/ResultFormatters/LogParserResultFormatter.cs index 1cce98b1..3c76d1bd 100644 --- a/CompatBot/Utils/ResultFormatters/LogParserResultFormatter.cs +++ b/CompatBot/Utils/ResultFormatters/LogParserResultFormatter.cs @@ -778,10 +778,15 @@ internal static partial class LogParserResult if (string.IsNullOrEmpty(currentBuildCommit)) currentBuildCommit = null; var updateInfo = await CompatClient.GetUpdateAsync(Config.Cts.Token, currentBuildCommit).ConfigureAwait(false); - if (updateInfo?.ReturnCode != 1 && currentBuildCommit != null) + if (updateInfo.ReturnCode != StatusCode.UpdatesAvailable && currentBuildCommit is not null) updateInfo = await CompatClient.GetUpdateAsync(Config.Cts.Token).ConfigureAwait(false); - var link = updateInfo?.LatestBuild?.Windows?.Download ?? updateInfo?.LatestBuild?.Linux?.Download ?? updateInfo?.LatestBuild?.Mac?.Download; - if (string.IsNullOrEmpty(link)) + var link = updateInfo.X64?.LatestBuild.Windows?.Download + ?? updateInfo.X64?.LatestBuild.Linux?.Download + ?? updateInfo.X64?.LatestBuild.Mac?.Download + ??updateInfo.Arm?.LatestBuild.Windows?.Download + ?? updateInfo.Arm?.LatestBuild.Linux?.Download + ?? updateInfo.Arm?.LatestBuild.Mac?.Download; + if (updateInfo.ReturnCode is not StatusCode.UpdatesAvailable || link is null) return null; var latestBuildInfo = BuildInfoInUpdate().Match(link.ToLowerInvariant()); @@ -791,7 +796,7 @@ internal static partial class LogParserResult return null; } - private static bool VersionIsTooOld(NameValueCollection items, Match update, UpdateInfo? updateInfo) + private static bool VersionIsTooOld(NameValueCollection items, Match update, UpdateInfo updateInfo) { if (updateInfo.GetUpdateDelta() is TimeSpan updateTimeDelta && updateTimeDelta < Config.BuildTimeDifferenceForOutdatedBuildsInDays) diff --git a/CompatBot/Utils/ResultFormatters/UpdateInfoFormatter.cs b/CompatBot/Utils/ResultFormatters/UpdateInfoFormatter.cs index e57de5ab..a9f01758 100644 --- a/CompatBot/Utils/ResultFormatters/UpdateInfoFormatter.cs +++ b/CompatBot/Utils/ResultFormatters/UpdateInfoFormatter.cs @@ -1,5 +1,6 @@ using System.IO; using System.Text.RegularExpressions; +using CompatApiClient; using CompatApiClient.POCOs; using CompatApiClient.Utils; using CompatBot.EventHandlers; @@ -13,18 +14,23 @@ internal static class UpdateInfoFormatter public static async Task AsEmbedAsync(this UpdateInfo? info, DiscordClient client, bool includePrBody = false, DiscordEmbedBuilder? builder = null, Octokit.PullRequest? currentPrInfo = null, bool useCurrent = false) { - if ((info?.LatestBuild?.Windows?.Download ?? info?.LatestBuild?.Linux?.Download ?? info?.LatestBuild?.Mac?.Download) is null) - return builder ?? new DiscordEmbedBuilder {Title = "Error", Description = "Error communicating with the update API. Try again later.", Color = Config.Colors.Maintenance}; + if ( info is not {ReturnCode: >=StatusCode.UnknownBuild}) + return builder ?? new DiscordEmbedBuilder + { + Title = "Error", + Description = "Error communicating with the update API. Try again later.", + Color = Config.Colors.Maintenance, + }; - var justAppend = builder != null; - var latestBuild = info!.LatestBuild; - var currentBuild = info.CurrentBuild; + var justAppend = builder is not null; + var latestBuild = info.X64?.LatestBuild ?? info.Arm?.LatestBuild; + var currentBuild = info.X64?.CurrentBuild ?? info.Arm?.CurrentBuild; var latestPr = latestBuild?.Pr; var currentPr = currentBuild?.Pr; string? url = null; Octokit.PullRequest? latestPrInfo = null; - string prDesc = ""; + var prDesc = ""; if (!justAppend) { if (latestPr > 0) @@ -46,7 +52,7 @@ internal static class UpdateInfoFormatter if (includePrBody && latestPrInfo?.Body is { Length: >0 } prInfoBody) desc = $"**{desc?.TrimEnd()}**\n\n{prInfoBody}"; desc = desc?.Trim(); - if (!string.IsNullOrEmpty(desc)) + if (desc is {Length: >0}) { if (GithubLinksHandler.IssueMention().Matches(desc) is { Count: >0 } issueMatches) { @@ -63,14 +69,14 @@ internal static class UpdateInfoFormatter { num = m.Groups["another_number"].Value; name = "#" + num; - if (!string.IsNullOrEmpty(m.Groups["comment_id"].Value)) + if (m.Groups["comment_id"].Value is {Length: >0}) name += " comment"; } - if (string.IsNullOrEmpty(num)) + if (num is not {Length: >0}) continue; var commentLink = ""; - if (!string.IsNullOrEmpty(m.Groups["comment_id"].Value)) + if (m.Groups["comment_id"].Value is {Length: >0}) commentLink = "#issuecomment-" + m.Groups["comment_id"].Value; var newLink = $"[{name}](https://github.com/RPCS3/rpcs3/issues/{num}{commentLink})"; desc = desc.Replace(str, newLink); @@ -85,7 +91,7 @@ internal static class UpdateInfoFormatter if (m.Groups["commit_mention"].Value is { Length: >0 } lnk && uniqueLinks.Add(lnk)) { var num = m.Groups["commit_hash"].Value; - if (string.IsNullOrEmpty(num)) + if (num is not {Length: >0}) continue; if (num.Length > 7) @@ -95,7 +101,7 @@ internal static class UpdateInfoFormatter } } } - if (!string.IsNullOrEmpty(desc) && GithubLinksHandler.ImageMarkup().Matches(desc) is {Count: >0} imgMatches) + if (desc is {Length: >0} && GithubLinksHandler.ImageMarkup().Matches(desc) is {Count: >0} imgMatches) { var uniqueLinks = new HashSet(10); foreach (Match m in imgMatches) @@ -104,9 +110,9 @@ internal static class UpdateInfoFormatter { var caption = m.Groups["img_caption"].Value; var link = m.Groups["img_link"].Value; - if (!string.IsNullOrEmpty(caption)) + if (caption is {Length: >0}) caption = " " + caption; - desc = desc.Replace(str, $"[🖼{caption}]({link})"); + desc = desc.Replace(str, $"[🖼️{caption}]({link})"); } } } @@ -115,11 +121,11 @@ internal static class UpdateInfoFormatter var currentCommit = currentPrInfo?.MergeCommitSha; var latestCommit = latestPrInfo?.MergeCommitSha; var buildTimestampKind = "Built"; - DateTime? latestBuildTimestamp = null, currentBuildTimestamp = null; - if (Config.GetAzureDevOpsClient() is {} azureClient) + DateTimeOffset? latestBuildTimestamp = null, currentBuildTimestamp = null; + //if (Config.GetAzureDevOpsClient() is {} azureClient) { - var currentAppveyorBuild = await azureClient.GetMasterBuildInfoAsync(currentCommit, currentPrInfo?.MergedAt?.DateTime, Config.Cts.Token).ConfigureAwait(false); - var latestAppveyorBuild = await azureClient.GetMasterBuildInfoAsync(latestCommit, latestPrInfo?.MergedAt?.DateTime, Config.Cts.Token).ConfigureAwait(false); + var currentAppveyorBuild = await GithubClient.GetMasterBuildInfoAsync(currentCommit, currentPrInfo?.MergedAt?.DateTime, Config.Cts.Token).ConfigureAwait(false); + var latestAppveyorBuild = await GithubClient.GetMasterBuildInfoAsync(latestCommit, latestPrInfo?.MergedAt?.DateTime, Config.Cts.Token).ConfigureAwait(false); latestBuildTimestamp = latestAppveyorBuild?.FinishTime; currentBuildTimestamp = currentAppveyorBuild?.FinishTime; if (!latestBuildTimestamp.HasValue) @@ -129,10 +135,11 @@ internal static class UpdateInfoFormatter } } - var linkedBuild = useCurrent ? currentBuild : latestBuild; - if (!string.IsNullOrEmpty(linkedBuild?.Datetime)) + var linkedX64Build = useCurrent ? info.X64?.CurrentBuild : info.X64?.LatestBuild; + var linkedArmBuild = useCurrent ? info.Arm?.CurrentBuild : info.Arm?.LatestBuild; + if ((linkedX64Build ?? linkedArmBuild)?.Datetime is {Length: >0} dateTime) { - var timestampInfo = (useCurrent ? currentBuildTimestamp : latestBuildTimestamp)?.ToString("u") ?? linkedBuild.Datetime; + var timestampInfo = (useCurrent ? currentBuildTimestamp : latestBuildTimestamp)?.ToString("u") ?? dateTime; if (!useCurrent && currentPr > 0 && currentPr != latestPr @@ -153,14 +160,17 @@ internal static class UpdateInfoFormatter builder.WithFooter($"{buildTimestampKind} on {timestampInfo}"); } return builder - .AddField("Windows download", GetLinkMessage(linkedBuild?.Windows, true), true) - .AddField("Linux download", GetLinkMessage(linkedBuild?.Linux, true), true) - .AddField("Mac download", GetLinkMessage(linkedBuild?.Mac, true), true); + .AddField("Windows x64", GetLinkMessage(linkedX64Build?.Windows, true), true) + .AddField("Linux x64", GetLinkMessage(linkedX64Build?.Linux, true), true) + .AddField("Mac Intel", GetLinkMessage(linkedX64Build?.Mac, true), true) + .AddField("Windows ARM64", "-", true) + .AddField("Linux ARM64", GetLinkMessage(linkedArmBuild?.Linux, true), true) + .AddField("Mac Apple Silicon", GetLinkMessage(linkedArmBuild?.Mac, true), true); } private static string GetLinkMessage(BuildLink? link, bool simpleName) { - if (link is null or { Download: null or "" } or { Size: null or 0 }) + if (link is not {Download.Length: >0, Size: >0}) return "No link available"; var text = new Uri(link.Download).Segments.Last(); @@ -203,19 +213,16 @@ internal static class UpdateInfoFormatter #endif }); - public static TimeSpan? GetUpdateDelta(DateTime? latest, DateTime? current) + public static TimeSpan? GetUpdateDelta(DateTimeOffset? latest, DateTimeOffset? current) { if (latest.HasValue && current.HasValue) return latest - current; return null; } - public static TimeSpan? GetUpdateDelta(this UpdateInfo? updateInfo) + public static TimeSpan? GetUpdateDelta(this UpdateInfo updateInfo) { - if (updateInfo?.LatestBuild?.Datetime is string latestDateTimeStr - && DateTime.TryParse(latestDateTimeStr, out var latestDateTime) - && updateInfo.CurrentBuild?.Datetime is string dateTimeBuildStr - && DateTime.TryParse(dateTimeBuildStr, out var dateTimeBuild)) + if (updateInfo is { LatestDatetime: DateTime latestDateTime, CurrentDatetime: DateTime dateTimeBuild }) return latestDateTime - dateTimeBuild; return null; }