From 8a9d92a4822eade12ec19f4e2e6f3ceca6e2513c Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 12 Oct 2024 15:08:59 -0400 Subject: [PATCH] Option to Offload non-technical PR discussion to a Discord Thread (#86068) This was discussed in #maintainerbus on the Discord. @tgstation/commit-access Result of this PR: - Add the `Discord Discussion` label to the PR. - The bot will create the thread, link it in the PR, and lock the PR. Slowmode set to 1 minute. Auto-archive duration set to max of 1 week. - The thread will be archived if the PR is merged or closed, unarchived if it's reopened. - You can also set a join link for the Discord to appear in the GitHub comment. I'll be setting it to our official link at https://tgstation13.org/phpBB/viewforum.php?f=60 ![image](https://github.com/user-attachments/assets/2cdbe762-2339-4467-8b80-d1bf939b43e4) ![image](https://github.com/user-attachments/assets/b5cb25a4-18ee-40a0-a3eb-c36cf38ca8dc) Let the bikeshed commence --- .github/workflows/discord_discussions.yml | 52 +++ .gitignore | 4 + .../DiscordForwardingResponder.cs | 28 ++ .../IDiscordResponders.cs | 9 + tools/Tgstation.DiscordDiscussions/Program.cs | 307 ++++++++++++++++++ .../PullRequestState.cs | 9 + .../Tgstation.DiscordDiscussions.csproj | 14 + .../Tgstation.DiscordDiscussions.sln | 25 ++ 8 files changed, 448 insertions(+) create mode 100644 .github/workflows/discord_discussions.yml create mode 100644 tools/Tgstation.DiscordDiscussions/DiscordForwardingResponder.cs create mode 100644 tools/Tgstation.DiscordDiscussions/IDiscordResponders.cs create mode 100644 tools/Tgstation.DiscordDiscussions/Program.cs create mode 100644 tools/Tgstation.DiscordDiscussions/PullRequestState.cs create mode 100644 tools/Tgstation.DiscordDiscussions/Tgstation.DiscordDiscussions.csproj create mode 100644 tools/Tgstation.DiscordDiscussions/Tgstation.DiscordDiscussions.sln diff --git a/.github/workflows/discord_discussions.yml b/.github/workflows/discord_discussions.yml new file mode 100644 index 00000000000..439315cbb95 --- /dev/null +++ b/.github/workflows/discord_discussions.yml @@ -0,0 +1,52 @@ +name: Discord Discussions + +on: + pull_request_target: + types: + - opened + - reopened + - edited + - labeled + - closed + branches: + - master + +concurrency: + group: "discord-discussions-${{ github.head_ref }}" + cancel-in-progress: true + +jobs: + manage-discord-discussion: + name: Manage Discord Discussion + runs-on: ubuntu-latest + if: contains(github.event.pull_request.labels.*.name, 'Discord Discussion') + steps: + - name: Fail if vars.DISCORD_DISCUSSIONS_CHANNEL_ID is unset + if: ${{ vars.DISCORD_DISCUSSIONS_CHANNEL_ID == '' }} + run: | + echo "vars.DISCORD_DISCUSSIONS_CHANNEL_ID (${{ vars.DISCORD_DISCUSSIONS_CHANNEL_ID }}) must be set to use this label!" + exit 1 + + - name: Setup dotnet + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + dotnet-quality: ga + + - name: Checkout + uses: actions/checkout@v4 + + - name: Build Tgstation.DiscordDiscussions + run: dotnet publish -c Release -o discord_discussions_bins tools/Tgstation.DiscordDiscussions/Tgstation.DiscordDiscussions.csproj + + - name: Generate App Token + id: app-token-generation + uses: getsentry/action-github-app-token@d4b5da6c5e37703f8c3b3e43abb5705b46e159cc + with: + app_id: ${{ secrets.APP_ID }} + private_key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Run Tgstation.DiscordDiscussions + run: dotnet discord_discussions_bins/Tgstation.DiscordDiscussions.dll ${{ steps.app-token-generation.outputs.token }} ${{ github.repository_owner }} ${{ github.event.repository.name }} ${{ github.event.pull_request.number }} ${{ github.event.pull_request.merged && 'merged' || github.event.pull_request.state }} ${{ secrets.DISCORD_DISCUSSIONS_TOKEN }} ${{ vars.DISCORD_DISCUSSIONS_CHANNEL_ID }} ${{ github.event.action == 'reopened' && 'true' || 'false' }} ${{ vars.DISCORD_JOIN_LINK }} + env: + GITHUB_PULL_REQUEST_TITLE: ${{ github.event.pull_request.title }} diff --git a/.gitignore b/.gitignore index 8ef9946abc9..28e074442df 100644 --- a/.gitignore +++ b/.gitignore @@ -180,6 +180,10 @@ Temporary Items /tools/MapAtmosFixer/MapAtmosFixer/bin/* /tools/CreditsTool/bin/* /tools/CreditsTool/obj/* +/tools/Tgstation.DiscordDiscussions/.vs/* +/tools/Tgstation.DiscordDiscussions/bin/* +/tools/Tgstation.DiscordDiscussions/obj/* +/tools/Tgstation.DiscordDiscussions/Properties/launchSettings.json #GitHub Atom .atom-build.json diff --git a/tools/Tgstation.DiscordDiscussions/DiscordForwardingResponder.cs b/tools/Tgstation.DiscordDiscussions/DiscordForwardingResponder.cs new file mode 100644 index 00000000000..4dab10fea27 --- /dev/null +++ b/tools/Tgstation.DiscordDiscussions/DiscordForwardingResponder.cs @@ -0,0 +1,28 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using Remora.Discord.API.Abstractions.Gateway.Events; +using Remora.Discord.Gateway.Responders; +using Remora.Results; + +namespace Tgstation.DiscordDiscussions +{ + /// + /// An that forwards to another . + /// + /// + /// Initializes a new instance of the class. + /// + /// The value of . + sealed class DiscordForwardingResponder(IDiscordResponders targetResponder) : IDiscordResponders + { + /// + /// The to forward the event to. + /// + readonly IDiscordResponders targetResponder = targetResponder ?? throw new ArgumentNullException(nameof(targetResponder)); + + /// + public Task RespondAsync(IReady gatewayEvent, CancellationToken ct) => targetResponder.RespondAsync(gatewayEvent, ct); + } +} diff --git a/tools/Tgstation.DiscordDiscussions/IDiscordResponders.cs b/tools/Tgstation.DiscordDiscussions/IDiscordResponders.cs new file mode 100644 index 00000000000..41f22d5b6f3 --- /dev/null +++ b/tools/Tgstation.DiscordDiscussions/IDiscordResponders.cs @@ -0,0 +1,9 @@ +using Remora.Discord.API.Abstractions.Gateway.Events; +using Remora.Discord.Gateway.Responders; + +namespace Tgstation.DiscordDiscussions +{ + internal interface IDiscordResponders : IResponder + { + } +} diff --git a/tools/Tgstation.DiscordDiscussions/Program.cs b/tools/Tgstation.DiscordDiscussions/Program.cs new file mode 100644 index 00000000000..1989ef61450 --- /dev/null +++ b/tools/Tgstation.DiscordDiscussions/Program.cs @@ -0,0 +1,307 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Extensions.DependencyInjection; + +using Octokit; + +using Remora.Discord.API.Abstractions.Gateway.Events; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.API.Objects; +using Remora.Discord.Gateway; +using Remora.Discord.Gateway.Extensions; +using Remora.Rest.Core; +using Remora.Rest.Results; +using Remora.Results; + +namespace Tgstation.DiscordDiscussions +{ + public sealed partial class Program : IDiscordResponders + { + const bool LockPullRequest = true; + const int InitSlowModeSeconds = 60; + + [GeneratedRegex(@"https://discord.com/channels/[0-9]+/([0-9]+)")] + private static partial Regex ChannelLinkRegex(); + + readonly TaskCompletionSource gatewayReadyTcs; + + public static Task Main(string[] args) + => new Program().RunAsync(args); + + /// + /// Converts a given into a log entry . + /// + /// The to convert. + /// Used internally for nesting. + /// The formatted . + static string LogFormat(IResult result, uint level = 0) + { + ArgumentNullException.ThrowIfNull(result); + + if (result.IsSuccess) + return "SUCCESS?"; + + var stringBuilder = new StringBuilder(); + if (result.Error != null) + { + stringBuilder.Append(result.Error.Message); + if (result.Error is RestResultError restError) + { + stringBuilder.Append(" ("); + if (restError.Error != null) + { + stringBuilder.Append(restError.Error.Code); + stringBuilder.Append(": "); + stringBuilder.Append(restError.Error.Message); + stringBuilder.Append('|'); + } + + stringBuilder.Append(restError.Message); + if ((restError.Error?.Errors.HasValue ?? false) && restError.Error.Errors.Value.Count > 0) + { + stringBuilder.Append(" ("); + foreach (var error in restError.Error.Errors.Value) + { + stringBuilder.Append(error.Key); + stringBuilder.Append(':'); + if (error.Value.IsT0) + { + FormatErrorDetails(error.Value.AsT0, stringBuilder); + } + else + FormatErrorDetails(error.Value.AsT1, stringBuilder); + stringBuilder.Append(','); + } + + stringBuilder.Remove(stringBuilder.Length - 1, 1); + } + + stringBuilder.Append(')'); + } + } + + if (result.Inner != null) + { + stringBuilder.Append(Environment.NewLine); + ++level; + for (var i = 0; i < level; ++i) + stringBuilder.Append('\t'); + stringBuilder.Append(LogFormat(result.Inner, level)); + } + + return stringBuilder.ToString(); + } + + /// + /// Formats given into a given . + /// + /// The . + /// The to mutate. + static void FormatErrorDetails(IPropertyErrorDetails propertyErrorDetails, StringBuilder stringBuilder) + { + if (propertyErrorDetails == null) + return; + + FormatErrorDetails(propertyErrorDetails.Errors, stringBuilder); + + if (propertyErrorDetails.Errors != null && propertyErrorDetails.MemberErrors != null) + { + stringBuilder.Append(','); + } + + if (propertyErrorDetails.MemberErrors != null) + { + stringBuilder.Append('{'); + foreach (var error in propertyErrorDetails.MemberErrors) + { + stringBuilder.Append(error.Key); + stringBuilder.Append(':'); + FormatErrorDetails(error.Value, stringBuilder); + stringBuilder.Append(','); + } + + stringBuilder.Remove(stringBuilder.Length - 1, 1); + stringBuilder.Append('}'); + } + } + + /// + /// Formats given into a given . + /// + /// The of . + /// The to mutate. + static void FormatErrorDetails(IEnumerable? errorDetails, StringBuilder stringBuilder) + { + if (errorDetails == null) + return; + + stringBuilder.Append('['); + foreach (var error in errorDetails) + { + stringBuilder.Append(error.Code); + stringBuilder.Append(':'); + stringBuilder.Append(error.Message); + stringBuilder.Append(','); + } + + stringBuilder.Remove(stringBuilder.Length - 1, 1); + stringBuilder.Append(']'); + } + + Program() + { + gatewayReadyTcs = new TaskCompletionSource(); + } + + async Task RunAsync(string[] args) + { + try + { + var gitHubToken = args[0]; + var repoOwner = args[1]; + var repoName = args[2]; + var prNumber = Int32.Parse(args[3]); + var state = Enum.Parse(args[4]); + var discordToken = args[5]; + var discussionsChannelId = UInt64.Parse(args[6]); + var isReopen = Boolean.Parse(args[7]); + var joinLink = args.Length > 8 ? args[8] : null; + + var prTitle = Environment.GetEnvironmentVariable("GITHUB_PULL_REQUEST_TITLE"); + + var gitHubClient = new GitHubClient(new ProductHeaderValue("Tgstation.DiscordDiscussions")) + { + Credentials = new Credentials(gitHubToken), + }; + + const string GitHubCommentPrefix = "Maintainers have requested non-technical related discussion regarding this pull request be moved to the Discord."; + + async ValueTask FindThreadID() + { + var comments = await gitHubClient.Issue.Comment.GetAllForIssue(repoOwner, repoName, prNumber); + + var commentInQuestion = comments.FirstOrDefault(comment => comment.Body.StartsWith(GitHubCommentPrefix)); + if (commentInQuestion == null) + return null; + + // https://discord.com/channels// + var threadId = UInt64.Parse(ChannelLinkRegex().Match(commentInQuestion.Body).Groups[1].Value); + return threadId; + } + + var threadIdTask = FindThreadID(); + + await using var serviceProvider = new ServiceCollection() + .AddDiscordGateway(serviceProvider => discordToken) + .AddSingleton(serviceProvider => (IDiscordResponders)this) + .AddResponder() + .BuildServiceProvider(); + + var gatewayClient = serviceProvider.GetRequiredService(); + using var gatewayCts = new CancellationTokenSource(); + var localGatewayTask = gatewayClient.RunAsync(gatewayCts.Token); + try + { + await gatewayReadyTcs.Task.WaitAsync(TimeSpan.FromMinutes(5)); + + var prLink = $"https://github.com/{repoOwner}/{repoName}/pull/{prNumber}"; + var messageContent = $"#{prNumber} - {prTitle}"; + + var channelsClient = serviceProvider.GetRequiredService(); + + var channelId = new Snowflake(discussionsChannelId); + + var threadId = await threadIdTask; + Snowflake messageId; + if (!threadId.HasValue) + { + var channel = await channelsClient.GetChannelAsync(channelId); + if (!channel.IsSuccess) + throw new Exception(LogFormat(channel)); + + var threadMessage = await channelsClient.StartThreadInForumChannelAsync(channelId, messageContent, AutoArchiveDuration.Week, InitSlowModeSeconds, $"Maintainers have requested that discussion for [this pull request]({prLink}) be moved here."); + if (!threadMessage.IsSuccess) + throw new Exception(LogFormat(threadMessage)); + + messageId = threadMessage.Entity.ID; + + var gitHubComment = $"{GitHubCommentPrefix}\nClick [here](https://discord.com/channels/{channel.Entity.GuildID.Value}/{messageId.Value}) to view the discussion."; + if (joinLink != null) + gitHubComment += $"\nClick [here]({joinLink}) to join the Discord!"; + + await gitHubClient.Issue.Comment.Create(repoOwner, repoName, prNumber, gitHubComment); + } + else + { + messageId = new Snowflake(threadId.Value); + + // open/close thread + if (state != PullRequestState.open) + { + var archiveMessage = await channelsClient.CreateMessageAsync(messageId, $"The associated pull request for this thread has been {state.ToString().ToLowerInvariant()}. This thread will now be archived."); + if (!archiveMessage.IsSuccess) + throw new Exception(LogFormat(archiveMessage)); + + var archiveAction = await channelsClient.ModifyThreadChannelAsync(messageId, messageContent, autoArchiveDuration: AutoArchiveDuration.Hour, isArchived: true); + if (!archiveAction.IsSuccess) + throw new Exception(LogFormat(archiveAction)); + } + else if (isReopen) + { + var unarchiveMessage = await channelsClient.CreateMessageAsync(messageId, "The associated pull request for this thread has been reopened. This thread will now be reopened."); + if (!unarchiveMessage.IsSuccess) + throw new Exception(LogFormat(unarchiveMessage)); + + var unarchiveAction = await channelsClient.ModifyThreadChannelAsync(messageId, messageContent, autoArchiveDuration: AutoArchiveDuration.Week, isArchived: false); + if (!unarchiveMessage.IsSuccess) + throw new Exception(LogFormat(unarchiveMessage)); + } + else + { + var response = await channelsClient.ModifyThreadChannelAsync(messageId, messageContent); + if (!response.IsSuccess) + throw new Exception(LogFormat(response)); + } + } + + // ensure the PR is locked + if (LockPullRequest) + { + await gitHubClient.PullRequest.LockUnlock.Lock(repoOwner, repoName, prNumber); + } + + return 0; + } + finally + { + gatewayCts.Cancel(); + try + { + await localGatewayTask.WaitAsync(TimeSpan.FromSeconds(10)); + } + catch (OperationCanceledException) + { + } + } + } + catch (Exception ex) + { + Console.WriteLine(ex.ToString()); + return 1; + } + } + + public Task RespondAsync(IReady gatewayEvent, CancellationToken ct = default) + { + gatewayReadyTcs.TrySetResult(); + return Task.FromResult(Result.FromSuccess()); + } + } +} diff --git a/tools/Tgstation.DiscordDiscussions/PullRequestState.cs b/tools/Tgstation.DiscordDiscussions/PullRequestState.cs new file mode 100644 index 00000000000..1420d3a4e9f --- /dev/null +++ b/tools/Tgstation.DiscordDiscussions/PullRequestState.cs @@ -0,0 +1,9 @@ +namespace Tgstation.DiscordDiscussions +{ + enum PullRequestState + { + closed, + open, + merged + } +} diff --git a/tools/Tgstation.DiscordDiscussions/Tgstation.DiscordDiscussions.csproj b/tools/Tgstation.DiscordDiscussions/Tgstation.DiscordDiscussions.csproj new file mode 100644 index 00000000000..8f8fa8d2f58 --- /dev/null +++ b/tools/Tgstation.DiscordDiscussions/Tgstation.DiscordDiscussions.csproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + enable + + + + + + + + diff --git a/tools/Tgstation.DiscordDiscussions/Tgstation.DiscordDiscussions.sln b/tools/Tgstation.DiscordDiscussions/Tgstation.DiscordDiscussions.sln new file mode 100644 index 00000000000..978f2901c3c --- /dev/null +++ b/tools/Tgstation.DiscordDiscussions/Tgstation.DiscordDiscussions.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.9.34728.123 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tgstation.DiscordDiscussions", "Tgstation.DiscordDiscussions.csproj", "{345EAB82-40E0-4F20-A4A6-8052CB8D1A01}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {345EAB82-40E0-4F20-A4A6-8052CB8D1A01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {345EAB82-40E0-4F20-A4A6-8052CB8D1A01}.Debug|Any CPU.Build.0 = Debug|Any CPU + {345EAB82-40E0-4F20-A4A6-8052CB8D1A01}.Release|Any CPU.ActiveCfg = Release|Any CPU + {345EAB82-40E0-4F20-A4A6-8052CB8D1A01}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {CBA935A0-8BBA-40CB-BC53-C339683858F4} + EndGlobalSection +EndGlobal