mirror of
https://github.com/Bubberstation/Bubberstation.git
synced 2025-12-09 16:05:07 +00:00
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   Let the bikeshed commence
This commit is contained in:
committed by
lessthanthree
parent
6c4dd673a3
commit
8a9d92a482
52
.github/workflows/discord_discussions.yml
vendored
Normal file
52
.github/workflows/discord_discussions.yml
vendored
Normal file
@@ -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 }}
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// An <see cref="IResponder{TGatewayEvent}"/> that forwards to another <see cref="targetResponder"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Initializes a new instance of the <see cref="DiscordForwardingResponder"/> class.
|
||||
/// </remarks>
|
||||
/// <param name="targetResponder">The value of <see cref="targetResponder"/>.</param>
|
||||
sealed class DiscordForwardingResponder(IDiscordResponders targetResponder) : IDiscordResponders
|
||||
{
|
||||
/// <summary>
|
||||
/// The <see cref="IResponder{TGatewayEvent}"/> to forward the event to.
|
||||
/// </summary>
|
||||
readonly IDiscordResponders targetResponder = targetResponder ?? throw new ArgumentNullException(nameof(targetResponder));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Result> RespondAsync(IReady gatewayEvent, CancellationToken ct) => targetResponder.RespondAsync(gatewayEvent, ct);
|
||||
}
|
||||
}
|
||||
9
tools/Tgstation.DiscordDiscussions/IDiscordResponders.cs
Normal file
9
tools/Tgstation.DiscordDiscussions/IDiscordResponders.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using Remora.Discord.API.Abstractions.Gateway.Events;
|
||||
using Remora.Discord.Gateway.Responders;
|
||||
|
||||
namespace Tgstation.DiscordDiscussions
|
||||
{
|
||||
internal interface IDiscordResponders : IResponder<IReady>
|
||||
{
|
||||
}
|
||||
}
|
||||
307
tools/Tgstation.DiscordDiscussions/Program.cs
Normal file
307
tools/Tgstation.DiscordDiscussions/Program.cs
Normal file
@@ -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<int> Main(string[] args)
|
||||
=> new Program().RunAsync(args);
|
||||
|
||||
/// <summary>
|
||||
/// Converts a given <paramref name="result"/> into a log entry <see cref="string"/>.
|
||||
/// </summary>
|
||||
/// <param name="result">The <see cref="IResult"/> to convert.</param>
|
||||
/// <param name="level">Used internally for nesting.</param>
|
||||
/// <returns>The <see cref="string"/> formatted <paramref name="result"/>.</returns>
|
||||
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> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats given <paramref name="propertyErrorDetails"/> into a given <paramref name="stringBuilder"/>.
|
||||
/// </summary>
|
||||
/// <param name="propertyErrorDetails">The <see cref="IPropertyErrorDetails"/>.</param>
|
||||
/// <param name="stringBuilder">The <see cref="StringBuilder"/> to mutate.</param>
|
||||
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('}');
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats given <paramref name="errorDetails"/> into a given <paramref name="stringBuilder"/>.
|
||||
/// </summary>
|
||||
/// <param name="errorDetails">The <see cref="IEnumerable{T}"/> of <see cref="IErrorDetails"/>.</param>
|
||||
/// <param name="stringBuilder">The <see cref="StringBuilder"/> to mutate.</param>
|
||||
static void FormatErrorDetails(IEnumerable<IErrorDetails>? 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<int> 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<PullRequestState>(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<ulong?> 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/<guild ID>/<thread ID>
|
||||
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<DiscordForwardingResponder>()
|
||||
.BuildServiceProvider();
|
||||
|
||||
var gatewayClient = serviceProvider.GetRequiredService<DiscordGatewayClient>();
|
||||
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<IDiscordRestChannelAPI>();
|
||||
|
||||
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<Result> RespondAsync(IReady gatewayEvent, CancellationToken ct = default)
|
||||
{
|
||||
gatewayReadyTcs.TrySetResult();
|
||||
return Task.FromResult(Result.FromSuccess());
|
||||
}
|
||||
}
|
||||
}
|
||||
9
tools/Tgstation.DiscordDiscussions/PullRequestState.cs
Normal file
9
tools/Tgstation.DiscordDiscussions/PullRequestState.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace Tgstation.DiscordDiscussions
|
||||
{
|
||||
enum PullRequestState
|
||||
{
|
||||
closed,
|
||||
open,
|
||||
merged
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Octokit" Version="13.0.1" />
|
||||
<PackageReference Include="Remora.Discord" Version="2024.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user