discord-bot/CompatBot/Commands/CompatList.Latest.cs
2025-04-05 17:07:43 +05:00

319 lines
16 KiB
C#

using System.Text.Json;
using CompatApiClient;
using CompatApiClient.POCOs;
using CompatBot.Database;
using CompatBot.EventHandlers;
using CompatBot.Utils.Extensions;
using CompatBot.Utils.ResultFormatters;
using DSharpPlus.Commands.Processors.TextCommands;
using Microsoft.EntityFrameworkCore;
using Microsoft.TeamFoundation.Build.WebApi;
using Octokit;
namespace CompatBot.Commands;
internal static partial class CompatList
{
[Command("latest"), AllowDMUsage]
public static class LatestBuild
{
[Command("build"), AllowDMUsage]
[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(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")]
public static ValueTask Clear(TextCommandContext ctx)
{
lastUpdateInfo = null;
lastFullBuildNumber = null;
return CheckForRpcs3UpdatesAsync(ctx);
}
[Command("set"), RequiresBotModRole]
[Description("Set the latest build info to the specified PR merge, and post new release announcements after it")]
public static ValueTask Set(TextCommandContext ctx, string lastUpdatePr)
{
lastUpdateInfo = lastUpdatePr;
lastFullBuildNumber = null;
return CheckForRpcs3UpdatesAsync(ctx);
}
public static async ValueTask CheckForRpcs3UpdatesAsync(
CommandContext? ctx = null,
DiscordClient? discordClient = null,
bool respond = false,
string? sinceCommit = null,
DiscordMessage? emptyBotMsg = null
)
{
if (ctx is null && respond)
throw new InvalidOperationException($"{nameof(Latest)}: request to respond without ctx");
discordClient ??= ctx?.Client;
if (discordClient is null)
throw new InvalidOperationException($"{nameof(Latest)}: no discord client provided");
var ephemeral = true;
if (respond)
{
ephemeral = !ctx!.Channel.IsSpamChannel();
if (ctx is SlashCommandContext sctx)
await sctx.DeferResponseAsync(ephemeral).ConfigureAwait(false);
}
var updateAnnouncementRestore = emptyBotMsg is not null;
var info = await Client.GetUpdateAsync(Config.Cts.Token, sinceCommit).ConfigureAwait(false);
if (info.ReturnCode != StatusCode.UpdatesAvailable && sinceCommit is not null)
info = await Client.GetUpdateAsync(Config.Cts.Token).ConfigureAwait(false);
if (updateAnnouncementRestore)
info.SetCurrentAsLatest();
var embed = await info.AsEmbedAsync(discordClient, !respond).ConfigureAwait(false);
if (info.ReturnCode < StatusCode.UnknownBuild || embed.Color?.Value == Config.Colors.Maintenance.Value)
{
if (updateAnnouncementRestore)
{
Config.Log.Debug($"Failed to get update info for commit {sinceCommit}: {JsonSerializer.Serialize(info)}");
return;
}
embed = await cachedUpdateInfo.AsEmbedAsync(discordClient, !respond).ConfigureAwait(false);
}
else if (!updateAnnouncementRestore)
{
if (cachedUpdateInfo?.LatestDatetime is DateTime previousBuildTime
&& info.LatestDatetime is DateTime newBuildTime
&& newBuildTime > previousBuildTime)
cachedUpdateInfo = info;
}
if (respond)
{
if (ctx is SlashCommandContext sctx)
await sctx.RespondAsync(embed: embed.Build(), ephemeral: ephemeral).ConfigureAwait(false);
else
await ctx!.RespondAsync(embed: embed.Build()).ConfigureAwait(false);
return;
}
if (updateAnnouncementRestore)
{
if (embed.Title is "Error")
return;
Config.Log.Debug($"Restoring update announcement for build {sinceCommit}: {embed.Title}\n{embed.Description}");
await emptyBotMsg!.ModifyAsync(embed: embed.Build()).ConfigureAwait(false);
return;
}
var latestUpdatePr = info.LatestPr?.ToString();
var match = (
from field in embed.Fields
let m = UpdateVersionRegex().Match(field.Value)
where m.Success
select m
).FirstOrDefault();
var latestUpdateBuild = match?.Groups["build"].Value;
if (latestUpdatePr is not {Length: >0}
|| lastUpdateInfo == latestUpdatePr
|| !await UpdateCheck.WaitAsync(0).ConfigureAwait(false))
return;
try
{
if (lastFullBuildNumber is {Length: >0}
&& latestUpdateBuild is {Length: >0}
&& int.TryParse(lastFullBuildNumber, out var lastSaveBuild)
&& int.TryParse(latestUpdateBuild, out var latestBuild)
&& latestBuild <= lastSaveBuild)
return;
var compatChannel = await discordClient.GetChannelAsync(Config.BotChannelId).ConfigureAwait(false);
var botMember = await discordClient.GetMemberAsync(compatChannel.Guild, discordClient.CurrentUser).ConfigureAwait(false);
if (botMember is null)
return;
if (!compatChannel.PermissionsFor(botMember).HasPermission(DiscordPermission.SendMessages))
{
NewBuildsMonitor.Reset();
return;
}
if (embed.Color?.Value == Config.Colors.Maintenance.Value)
return;
await CheckMissedBuildsBetweenAsync(discordClient, compatChannel, lastUpdateInfo, latestUpdatePr, Config.Cts.Token).ConfigureAwait(false);
await compatChannel.SendMessageAsync(embed: embed.Build()).ConfigureAwait(false);
lastUpdateInfo = latestUpdatePr;
lastFullBuildNumber = latestUpdateBuild;
await using (var wdb = await BotDb.OpenWriteAsync().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);
else
currentState.Value = latestUpdatePr;
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);
else
savedLastBuild.Value = latestUpdateBuild;
await wdb.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false);
}
NewBuildsMonitor.Reset();
}
catch (Exception e)
{
Config.Log.Warn(e, "Failed to check for RPCS3 update info");
}
finally
{
UpdateCheck.Release();
}
}
private static async ValueTask CheckMissedBuildsBetweenAsync(DiscordClient discordClient, DiscordChannel compatChannel, string? previousUpdatePr, string? latestUpdatePr, CancellationToken cancellationToken)
{
if (!int.TryParse(previousUpdatePr, out var oldestPr)
|| !int.TryParse(latestUpdatePr, out var newestPr))
return;
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 is null || oldestPrCommit?.MergedAt is null)
return;
mergedPrs = mergedPrs?.Where(pri => pri.MergedAt.HasValue)
.OrderBy(pri => pri.MergedAt!.Value)
.SkipWhile(pri => pri.Number != oldestPr)
.Skip(1)
.TakeWhile(pri => pri.Number != newestPr)
.ToList();
if (mergedPrs is not {Count: >0})
return;
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,
cancellationToken
).ConfigureAwait(false);
foreach (var mergedPr in mergedPrs)
{
var updateInfo = await Client.GetUpdateAsync(cancellationToken, mergedPr.MergeCommitSha).ConfigureAwait(false);
if (updateInfo.ReturnCode >= StatusCode.NoUpdates) // latest or known build
{
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 is StatusCode.UnknownBuild)
{
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()
{
X64 = new()
{
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 ?? "" },
},
},
};
}
else
{
updateInfo = new()
{
X64 = new()
{
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 = 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",
BuildResult.Failed => "Failed to build",
BuildResult.Canceled => "Cancelled",
_ => 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");
await compatChannel.SendMessageAsync(embed: embed.Build()).ConfigureAwait(false);
}
}
}
}
}