diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fdc7f39..ffa172e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,7 @@ jobs: - name: Publish (linux-x64, self-contained, single file) run: | dotnet publish \ + abaci-bot.csproj \ -c Release \ -r linux-x64 \ --self-contained true \ @@ -64,4 +65,3 @@ jobs: with: files: ./dist/abaci-bot generate_release_notes: true - diff --git a/.gitignore b/.gitignore index f900ce1..29ce006 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,4 @@ appsettings.json .idea .vs/ .vscode/ +Properties/launchSettings.json diff --git a/Program.cs b/Program.cs index df60b39..52c0777 100644 --- a/Program.cs +++ b/Program.cs @@ -29,7 +29,7 @@ return; } -builder.Services.AddSingleton(new GitHubService( +builder.Services.AddSingleton(new GitHubService( builder.Configuration.GetValue("GitHubApp:AppId"), builder.Configuration["GitHubApp:PrivateKey"]!, builder.Configuration.GetValue("GitHubApp:InstallationId") diff --git a/Services/GitHubLabels.cs b/Services/GitHubLabels.cs new file mode 100644 index 0000000..018b427 --- /dev/null +++ b/Services/GitHubLabels.cs @@ -0,0 +1,12 @@ +namespace abaci_bot.Services; + +public static class GitHubLabels +{ + public const string WorkflowBlocked = "Workflow: Blocked"; + public const string WorkflowInDev = "Workflow: In Dev"; + public const string WorkflowReadyForReview = "Workflow: Ready For Review"; + public const string WorkflowInReview = "Workflow: In Review"; + public const string WorkflowComplete = "Workflow: Complete"; + public const string CommitsUpdated = "Commits: Updated"; + public const string AiAssistance = "AI Assistance"; +} diff --git a/Services/GitHubService.cs b/Services/GitHubService.cs index 90bbcad..a4a4761 100644 --- a/Services/GitHubService.cs +++ b/Services/GitHubService.cs @@ -5,7 +5,7 @@ namespace abaci_bot.Services; -public class GitHubService +public class GitHubService : IGitHubService { private readonly int _appId; private readonly string _privateKey; @@ -91,7 +91,7 @@ public async Task> GetPullRequestFilesAsync( return await _client.PullRequest.Files(owner, repo, prNumber); } - public async Task GetPullRequestAuthorEmailAsync( + public async Task GetPullRequestAuthorEmailAsync( string owner, string repo, int prNumber) { await EnsureAuthenticatedAsync(); diff --git a/Services/GitHubWebhookProcessor.cs b/Services/GitHubWebhookProcessor.cs index 369c248..ef61575 100644 --- a/Services/GitHubWebhookProcessor.cs +++ b/Services/GitHubWebhookProcessor.cs @@ -8,10 +8,10 @@ namespace abaci_bot.Services; public class GitHubWebhookProcessor : WebhookEventProcessor { - private readonly GitHubService _github; + private readonly IGitHubService _github; private readonly IConfiguration _config; - public GitHubWebhookProcessor(GitHubService github, IConfiguration config) + public GitHubWebhookProcessor(IGitHubService github, IConfiguration config) { _github = github; _config = config; @@ -24,11 +24,13 @@ protected override async ValueTask ProcessPullRequestWebhookAsync( CancellationToken cancellationToken = default) { var prNumber = (int)pullRequestEvent.PullRequest.Number; - var owner = pullRequestEvent.Repository.Owner.Login; - var repo = pullRequestEvent.Repository.Name; + if (!TryGetRepositoryContext(pullRequestEvent.Repository, out var owner, out var repo)) + return; + var headSha = pullRequestEvent.PullRequest.Head.Sha; var isDraft = pullRequestEvent.PullRequest.Draft; - var currentLabels = pullRequestEvent.PullRequest.Labels.Select(l => l.Name).ToList(); + var currentLabels = GetLabelNames(pullRequestEvent.PullRequest.Labels); + var isBlocked = currentLabels.Contains(GitHubLabels.WorkflowBlocked); string prTitle = pullRequestEvent.PullRequest.Title; // Handle PR when opened or synchronized (new commit) @@ -39,9 +41,9 @@ protected override async ValueTask ProcessPullRequestWebhookAsync( action == PullRequestAction.ReadyForReview) { // When PR is synchronized (new commits) and has Workflow: Blocked label, add Commits: Updated - if (action == PullRequestAction.Synchronize && currentLabels.Contains("Workflow: Blocked")) + if (action == PullRequestAction.Synchronize && isBlocked) { - await _github.AddLabelsAsync(owner, repo, prNumber, "Commits: Updated"); + await _github.AddLabelsAsync(owner, repo, prNumber, GitHubLabels.CommitsUpdated); } // Keep "AI Assistance" label in sync with the PR description. await AnalyzeDescriptionAndLabelPR(owner, repo, prNumber, pullRequestEvent.PullRequest.Body); @@ -62,20 +64,25 @@ protected override async ValueTask ProcessPullRequestWebhookAsync( // Then, let's analyze the content of the PR and add labels accordingly. await AnalyzeFilesAndLabelPR(owner, repo, prNumber, headSha); + if (isBlocked) + { + return; + } + if (isDraft || prTitle.StartsWith("WIP", StringComparison.OrdinalIgnoreCase)) { // For draft PR, add "Workflow: In Dev" label to indicate it's still in development, // and remove "Workflow: Ready For Review" label if exists. - await _github.AddLabelsAsync(owner, repo, prNumber, "Workflow: In Dev"); - await _github.RemoveLabelAsync(owner, repo, prNumber, "Workflow: Ready For Review"); + await _github.AddLabelsAsync(owner, repo, prNumber, GitHubLabels.WorkflowInDev); + await _github.RemoveLabelAsync(owner, repo, prNumber, GitHubLabels.WorkflowReadyForReview); } else { // For non-draft PR, add "Workflow: Ready For Review" label and remove "Workflow: In Dev" label if exists. - await _github.RemoveLabelAsync(owner, repo, prNumber, "Workflow: In Dev"); - if (!currentLabels.Contains("Workflow: In Review")) + await _github.RemoveLabelAsync(owner, repo, prNumber, GitHubLabels.WorkflowInDev); + if (!currentLabels.Contains(GitHubLabels.WorkflowInReview)) { - await _github.AddLabelsAsync(owner, repo, prNumber, "Workflow: Ready For Review"); + await _github.AddLabelsAsync(owner, repo, prNumber, GitHubLabels.WorkflowReadyForReview); } } @@ -91,21 +98,29 @@ protected override async ValueTask ProcessPullRequestWebhookAsync( if (pullRequestEvent.PullRequest.Merged == true) { // For merged PR, add "Workflow: Complete" label. - await _github.AddLabelsAsync(owner, repo, prNumber, "Workflow: Complete"); + await _github.AddLabelsAsync(owner, repo, prNumber, GitHubLabels.WorkflowComplete); } - await _github.RemoveLabelAsync(owner, repo, prNumber, "Workflow: In Dev"); - await _github.RemoveLabelAsync(owner, repo, prNumber, "Workflow: Ready For Review"); - await _github.RemoveLabelAsync(owner, repo, prNumber, "Workflow: In Review"); + await _github.RemoveLabelAsync(owner, repo, prNumber, GitHubLabels.WorkflowInDev); + await _github.RemoveLabelAsync(owner, repo, prNumber, GitHubLabels.WorkflowReadyForReview); + await _github.RemoveLabelAsync(owner, repo, prNumber, GitHubLabels.WorkflowInReview); } // Handle PR labeled event for Workflow: Blocked management else if (action == PullRequestAction.Labeled) { - await HandleBlockedLabelAdded(owner, repo, prNumber); + if (pullRequestEvent is PullRequestLabeledEvent labeledEvent && + IsBlockedLabel(labeledEvent.Label.Name)) + { + await HandleBlockedLabelAdded(owner, repo, prNumber); + } } // Handle PR unlabeled event for Workflow: Blocked management else if (action == PullRequestAction.Unlabeled) { - await HandleBlockedLabelRemoved(owner, repo, prNumber); + if (pullRequestEvent is PullRequestUnlabeledEvent unlabeledEvent && + IsBlockedLabel(unlabeledEvent.Label.Name)) + { + await HandleBlockedLabelRemoved(owner, repo, prNumber); + } } } @@ -118,20 +133,29 @@ protected override async ValueTask ProcessPullRequestReviewWebhookAsync( if (action == PullRequestReviewAction.Submitted) { var prNumber = (int)pullRequestReviewEvent.PullRequest.Number; - var owner = pullRequestReviewEvent.Repository.Owner.Login; - var repo = pullRequestReviewEvent.Repository.Name; + if (!TryGetRepositoryContext(pullRequestReviewEvent.Repository, out var owner, out var repo)) + return; + + if (!TryGetSender(pullRequestReviewEvent.Sender, out var sender)) + return; + var captains = await _github.GetTeamMembersAsync(owner, _config["GitHubApp:TeamName"]!); - string sender = pullRequestReviewEvent.Sender.Login.ToLowerInvariant(); + string? author = pullRequestReviewEvent.PullRequest.User?.Login; + var currentLabels = GetLabelNames(pullRequestReviewEvent.PullRequest.Labels); + var isBlocked = currentLabels.Contains(GitHubLabels.WorkflowBlocked); // If the review is submitted by a captain, add "Workflow: In Review" label. - if (captains.Contains(sender)) + if (captains.Contains(sender) && !isBlocked) { - await _github.RemoveLabelAsync(owner, repo, prNumber, "Workflow: Ready For Review"); - await _github.AddLabelsAsync(owner, repo, prNumber, "Workflow: In Review"); + await _github.RemoveLabelAsync(owner, repo, prNumber, GitHubLabels.WorkflowReadyForReview); + await _github.AddLabelsAsync(owner, repo, prNumber, GitHubLabels.WorkflowInReview); } - // Remove "Commits: Updated" label when a review is submitted - await _github.RemoveLabelAsync(owner, repo, prNumber, "Commits: Updated"); + // A non-author captain response acknowledges the author's update. + if (captains.Contains(sender) && !IsSameUser(sender, author)) + { + await _github.RemoveLabelAsync(owner, repo, prNumber, GitHubLabels.CommitsUpdated); + } } } @@ -147,25 +171,35 @@ protected override async ValueTask ProcessIssueCommentWebhookAsync( if (issueCommentEvent.Issue.PullRequest != null) { var prNumber = (int)issueCommentEvent.Issue.Number; - var owner = issueCommentEvent.Repository.Owner.Login; - var repo = issueCommentEvent.Repository.Name; + if (!TryGetRepositoryContext(issueCommentEvent.Repository, out var owner, out var repo)) + return; + + if (!TryGetSender(issueCommentEvent.Sender, out var sender)) + return; + var captains = await _github.GetTeamMembersAsync(owner, _config["GitHubApp:TeamName"]!); - string sender = issueCommentEvent.Sender.Login.ToLowerInvariant(); var pullRequest = await _github.GetPullRequestAsync(owner, repo, prNumber); + string? author = issueCommentEvent.Issue.User?.Login ?? pullRequest.User?.Login; var prTitle = issueCommentEvent.Issue.Title; + var currentLabels = GetLabelNames(issueCommentEvent.Issue.Labels); + var isBlocked = currentLabels.Contains(GitHubLabels.WorkflowBlocked); // Only when the commenter is a captain, the PR is not in draft, // and the title doesn't start with "WIP", we consider it as "In Review" and add the label. if (captains.Contains(sender) && + !isBlocked && !pullRequest.Draft && !prTitle.StartsWith("WIP", StringComparison.OrdinalIgnoreCase)) { - await _github.RemoveLabelAsync(owner, repo, prNumber, "Workflow: Ready For Review"); - await _github.AddLabelsAsync(owner, repo, prNumber, "Workflow: In Review"); + await _github.RemoveLabelAsync(owner, repo, prNumber, GitHubLabels.WorkflowReadyForReview); + await _github.AddLabelsAsync(owner, repo, prNumber, GitHubLabels.WorkflowInReview); } - // Remove "Commits: Updated" label when there's a comment from contributor - await _github.RemoveLabelAsync(owner, repo, prNumber, "Commits: Updated"); + // A non-author captain response acknowledges the author's update. + if (captains.Contains(sender) && !IsSameUser(sender, author)) + { + await _github.RemoveLabelAsync(owner, repo, prNumber, GitHubLabels.CommitsUpdated); + } } } } @@ -173,14 +207,14 @@ protected override async ValueTask ProcessIssueCommentWebhookAsync( // When Workflow: Blocked label is added, remove In Review and Ready For Review labels private async Task HandleBlockedLabelAdded(string owner, string repo, int prNumber) { - await _github.RemoveLabelAsync(owner, repo, prNumber, "Workflow: In Review"); - await _github.RemoveLabelAsync(owner, repo, prNumber, "Workflow: Ready For Review"); + await _github.RemoveLabelAsync(owner, repo, prNumber, GitHubLabels.WorkflowInReview); + await _github.RemoveLabelAsync(owner, repo, prNumber, GitHubLabels.WorkflowReadyForReview); } // When Workflow: Blocked label is removed, restore In Review label private async Task HandleBlockedLabelRemoved(string owner, string repo, int prNumber) { - await _github.AddLabelsAsync(owner, repo, prNumber, "Workflow: In Review"); + await _github.AddLabelsAsync(owner, repo, prNumber, GitHubLabels.WorkflowInReview); } private async Task AnalyzeUserAndLabelPR(string owner, string repo, int prNumber, string? userEmail) @@ -273,9 +307,46 @@ private async Task AnalyzeDescriptionAndLabelPR(string owner, string repo, int p // If the checkbox is checked, add the label. // Otherwise, remove it to avoid stale label state. if (IsAiAssistedPullRequest(body)) - await _github.AddLabelsAsync(owner, repo, prNumber, "AI Assistance"); + await _github.AddLabelsAsync(owner, repo, prNumber, GitHubLabels.AiAssistance); else - await _github.RemoveLabelAsync(owner, repo, prNumber, "AI Assistance"); + await _github.RemoveLabelAsync(owner, repo, prNumber, GitHubLabels.AiAssistance); + } + + private static HashSet GetLabelNames(IEnumerable? labels) + { + return labels? + .Select(label => label.Name) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .ToHashSet(StringComparer.OrdinalIgnoreCase) ?? new HashSet(StringComparer.OrdinalIgnoreCase); + } + + private static bool TryGetRepositoryContext( + Octokit.Webhooks.Models.Repository? repository, + out string owner, + out string repo) + { + owner = repository?.Owner?.Login ?? string.Empty; + repo = repository?.Name ?? string.Empty; + + return !string.IsNullOrWhiteSpace(owner) && !string.IsNullOrWhiteSpace(repo); + } + + private static bool TryGetSender(Octokit.Webhooks.Models.User? sender, out string login) + { + login = sender?.Login?.ToLowerInvariant() ?? string.Empty; + return !string.IsNullOrWhiteSpace(login); + } + + private static bool IsBlockedLabel(string? labelName) + { + return string.Equals(labelName, GitHubLabels.WorkflowBlocked, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsSameUser(string? left, string? right) + { + return !string.IsNullOrWhiteSpace(left) && + !string.IsNullOrWhiteSpace(right) && + string.Equals(left, right, StringComparison.OrdinalIgnoreCase); } private static string? ExtractEmailDomain(string? email) diff --git a/Services/IGitHubService.cs b/Services/IGitHubService.cs new file mode 100644 index 0000000..b805614 --- /dev/null +++ b/Services/IGitHubService.cs @@ -0,0 +1,27 @@ +using Octokit; + +namespace abaci_bot.Services; + +public interface IGitHubService +{ + Task> GetPullRequestFilesAsync( + string owner, string repo, int prNumber); + + Task GetPullRequestAuthorEmailAsync( + string owner, string repo, int prNumber); + + Task GetFileContentAsync( + string owner, string repo, string path, string sha); + + Task AddLabelsAsync( + string owner, string repo, int issueNumber, params string[] labels); + + Task RemoveLabelAsync( + string owner, string repo, int issueNumber, string label); + + Task> GetTeamMembersAsync( + string owner, string teamName); + + Task GetPullRequestAsync( + string owner, string repo, int prNumber); +} diff --git a/abaci-bot.csproj b/abaci-bot.csproj index a0fcbea..65c326e 100644 --- a/abaci-bot.csproj +++ b/abaci-bot.csproj @@ -14,4 +14,11 @@ + + + + + + + diff --git a/abaci-bot.sln b/abaci-bot.sln index 159fedd..5df5fba 100644 --- a/abaci-bot.sln +++ b/abaci-bot.sln @@ -1,7 +1,8 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "abaci-bot", "abaci-bot.csproj", "{DB9EB766-0713-4D0A-A2C2-A91C9558FD57}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "abaci-bot.Tests", "tests\abaci-bot.Tests\abaci-bot.Tests.csproj", "{10857AF0-5B6C-4C46-807C-EC9B5F6AD1B0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -12,5 +13,12 @@ Global {DB9EB766-0713-4D0A-A2C2-A91C9558FD57}.Debug|Any CPU.Build.0 = Debug|Any CPU {DB9EB766-0713-4D0A-A2C2-A91C9558FD57}.Release|Any CPU.ActiveCfg = Release|Any CPU {DB9EB766-0713-4D0A-A2C2-A91C9558FD57}.Release|Any CPU.Build.0 = Release|Any CPU + {10857AF0-5B6C-4C46-807C-EC9B5F6AD1B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {10857AF0-5B6C-4C46-807C-EC9B5F6AD1B0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {10857AF0-5B6C-4C46-807C-EC9B5F6AD1B0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {10857AF0-5B6C-4C46-807C-EC9B5F6AD1B0}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection EndGlobal diff --git a/tests/abaci-bot.Tests/GitHubWebhookProcessorTests.cs b/tests/abaci-bot.Tests/GitHubWebhookProcessorTests.cs new file mode 100644 index 0000000..63f244b --- /dev/null +++ b/tests/abaci-bot.Tests/GitHubWebhookProcessorTests.cs @@ -0,0 +1,353 @@ +using abaci_bot.Services; +using Microsoft.Extensions.Configuration; +using Octokit.Webhooks; +using Octokit.Webhooks.Events; +using Octokit.Webhooks.Events.IssueComment; +using Octokit.Webhooks.Events.PullRequest; +using Octokit.Webhooks.Events.PullRequestReview; +using OctokitPullRequest = Octokit.PullRequest; +using OctokitPullRequestFile = Octokit.PullRequestFile; +using WebhookIssue = Octokit.Webhooks.Models.Issue; +using WebhookIssueCommentCreatedEvent = Octokit.Webhooks.Events.IssueComment.IssueCommentCreatedEvent; +using WebhookIssuePullRequest = Octokit.Webhooks.Models.IssuePullRequest; +using WebhookLabel = Octokit.Webhooks.Models.Label; +using WebhookPullRequest = Octokit.Webhooks.Models.PullRequestEvent.PullRequest; +using WebhookPullRequestHead = Octokit.Webhooks.Models.PullRequestEvent.PullRequestHead; +using WebhookPullRequestReviewEvent = Octokit.Webhooks.Events.PullRequestReviewEvent; +using WebhookPullRequestReviewSubmittedEvent = Octokit.Webhooks.Events.PullRequestReview.PullRequestReviewSubmittedEvent; +using WebhookRepository = Octokit.Webhooks.Models.Repository; +using WebhookSimplePullRequest = Octokit.Webhooks.Models.SimplePullRequest; +using WebhookUser = Octokit.Webhooks.Models.User; + +namespace abaci_bot.Tests; + +public class GitHubWebhookProcessorTests +{ + [Theory] + [InlineData(true, GitHubLabels.WorkflowInReview)] + [InlineData(false, GitHubLabels.WorkflowInReview)] + [InlineData(true, GitHubLabels.WorkflowReadyForReview)] + [InlineData(false, GitHubLabels.WorkflowReadyForReview)] + [InlineData(true, GitHubLabels.CommitsUpdated)] + [InlineData(false, GitHubLabels.CommitsUpdated)] + public async Task NonBlockedLabelChangesDoNotRunBlockedWorkflow(bool isLabeled, string labelName) + { + var github = new FakeGitHubService(); + var processor = CreateProcessor(github); + PullRequestEvent payload = isLabeled + ? LabeledEvent(labelName) + : UnlabeledEvent(labelName); + var action = isLabeled ? PullRequestAction.Labeled : PullRequestAction.Unlabeled; + + await processor.ProcessPullRequestAsync(payload, action); + + Assert.Empty(github.LabelOperations); + } + + [Fact] + public async Task BlockedLabelAddedRemovesReviewWorkflowLabels() + { + var github = new FakeGitHubService(); + var processor = CreateProcessor(github); + + await processor.ProcessPullRequestAsync(LabeledEvent(GitHubLabels.WorkflowBlocked), PullRequestAction.Labeled); + + Assert.Equal( + new[] { $"remove:{GitHubLabels.WorkflowInReview}", $"remove:{GitHubLabels.WorkflowReadyForReview}" }, + github.LabelOperations); + } + + [Fact] + public async Task BlockedLabelRemovedRestoresInReview() + { + var github = new FakeGitHubService(); + var processor = CreateProcessor(github); + + await processor.ProcessPullRequestAsync(UnlabeledEvent(GitHubLabels.WorkflowBlocked), PullRequestAction.Unlabeled); + + Assert.Equal(new[] { $"add:{GitHubLabels.WorkflowInReview}" }, github.LabelOperations); + } + + [Fact] + public async Task BlockedSynchronizeAddsCommitsUpdatedButDoesNotAddReadyForReview() + { + var github = new FakeGitHubService(); + var processor = CreateProcessor(github); + var payload = PullRequestEvent(PullRequestAction.Synchronize, labels: new[] { GitHubLabels.WorkflowBlocked }); + + await processor.ProcessPullRequestAsync(payload, PullRequestAction.Synchronize); + + Assert.Contains($"add:{GitHubLabels.CommitsUpdated}", github.LabelOperations); + Assert.DoesNotContain($"add:{GitHubLabels.WorkflowReadyForReview}", github.LabelOperations); + } + + [Fact] + public async Task AuthorCommentDoesNotRemoveCommitsUpdated() + { + var github = new FakeGitHubService(); + var processor = CreateProcessor(github); + + await processor.ProcessIssueCommentAsync(IssueCommentEvent(author: "contributor", sender: "contributor"), IssueCommentAction.Created); + + Assert.DoesNotContain($"remove:{GitHubLabels.CommitsUpdated}", github.LabelOperations); + } + + [Fact] + public async Task CaptainCommentRemovesCommitsUpdated() + { + var github = new FakeGitHubService(); + var processor = CreateProcessor(github); + + await processor.ProcessIssueCommentAsync(IssueCommentEvent(author: "contributor", sender: "captain"), IssueCommentAction.Created); + + Assert.Contains($"remove:{GitHubLabels.CommitsUpdated}", github.LabelOperations); + } + + [Fact] + public async Task AuthorCaptainCommentDoesNotRemoveCommitsUpdated() + { + var github = new FakeGitHubService(); + var processor = CreateProcessor(github); + + await processor.ProcessIssueCommentAsync(IssueCommentEvent(author: "captain", sender: "captain"), IssueCommentAction.Created); + + Assert.DoesNotContain($"remove:{GitHubLabels.CommitsUpdated}", github.LabelOperations); + } + + [Fact] + public async Task AuthorReviewDoesNotRemoveCommitsUpdated() + { + var github = new FakeGitHubService(); + var processor = CreateProcessor(github); + + await processor.ProcessPullRequestReviewAsync(PullRequestReviewEvent(author: "contributor", sender: "contributor"), PullRequestReviewAction.Submitted); + + Assert.DoesNotContain($"remove:{GitHubLabels.CommitsUpdated}", github.LabelOperations); + } + + [Fact] + public async Task CaptainReviewRemovesCommitsUpdated() + { + var github = new FakeGitHubService(); + var processor = CreateProcessor(github); + + await processor.ProcessPullRequestReviewAsync(PullRequestReviewEvent(author: "contributor", sender: "captain"), PullRequestReviewAction.Submitted); + + Assert.Contains($"remove:{GitHubLabels.CommitsUpdated}", github.LabelOperations); + } + + [Fact] + public async Task AuthorCaptainReviewDoesNotRemoveCommitsUpdated() + { + var github = new FakeGitHubService(); + var processor = CreateProcessor(github); + + await processor.ProcessPullRequestReviewAsync(PullRequestReviewEvent(author: "captain", sender: "captain"), PullRequestReviewAction.Submitted); + + Assert.DoesNotContain($"remove:{GitHubLabels.CommitsUpdated}", github.LabelOperations); + } + + private static TestGitHubWebhookProcessor CreateProcessor(FakeGitHubService github) + { + var config = new ConfigurationManager + { + ["GitHubApp:TeamName"] = "captains" + }; + + return new TestGitHubWebhookProcessor(github, config); + } + + private static PullRequestLabeledEvent LabeledEvent(string labelName) + { + var pullRequest = PullRequest(); + return Create( + (nameof(PullRequestLabeledEvent.Number), pullRequest.Number), + (nameof(PullRequestLabeledEvent.PullRequest), pullRequest), + (nameof(PullRequestLabeledEvent.Repository), Repository()), + (nameof(PullRequestLabeledEvent.Sender), User("maintainer")), + (nameof(PullRequestLabeledEvent.Label), Label(labelName))); + } + + private static PullRequestUnlabeledEvent UnlabeledEvent(string labelName) + { + var pullRequest = PullRequest(); + return Create( + (nameof(PullRequestUnlabeledEvent.Number), pullRequest.Number), + (nameof(PullRequestUnlabeledEvent.PullRequest), pullRequest), + (nameof(PullRequestUnlabeledEvent.Repository), Repository()), + (nameof(PullRequestUnlabeledEvent.Sender), User("maintainer")), + (nameof(PullRequestUnlabeledEvent.Label), Label(labelName))); + } + + private static PullRequestSynchronizeEvent PullRequestEvent( + PullRequestAction action, + IReadOnlyList? labels = null) + { + var pullRequest = PullRequest(labels: labels); + return Create( + (nameof(PullRequestSynchronizeEvent.Number), pullRequest.Number), + (nameof(PullRequestSynchronizeEvent.PullRequest), pullRequest), + (nameof(PullRequestSynchronizeEvent.Repository), Repository()), + (nameof(PullRequestSynchronizeEvent.Sender), User("contributor"))); + } + + private static WebhookIssueCommentCreatedEvent IssueCommentEvent(string author, string sender, IReadOnlyList? labels = null) + { + var issue = Create( + (nameof(WebhookIssue.Number), 1L), + (nameof(WebhookIssue.Title), "Ready PR"), + (nameof(WebhookIssue.User), User(author)), + (nameof(WebhookIssue.PullRequest), Create()), + (nameof(WebhookIssue.Labels), Labels(labels))); + + return Create( + (nameof(WebhookIssueCommentCreatedEvent.Issue), issue), + (nameof(WebhookIssueCommentCreatedEvent.Repository), Repository()), + (nameof(WebhookIssueCommentCreatedEvent.Sender), User(sender))); + } + + private static WebhookPullRequestReviewEvent PullRequestReviewEvent(string author, string sender, IReadOnlyList? labels = null) + { + var pullRequest = Create( + (nameof(WebhookSimplePullRequest.Number), 1L), + (nameof(WebhookSimplePullRequest.Title), "Ready PR"), + (nameof(WebhookSimplePullRequest.User), User(author)), + (nameof(WebhookSimplePullRequest.Labels), Labels(labels))); + + return Create( + (nameof(WebhookPullRequestReviewSubmittedEvent.PullRequest), pullRequest), + (nameof(WebhookPullRequestReviewSubmittedEvent.Repository), Repository()), + (nameof(WebhookPullRequestReviewSubmittedEvent.Sender), User(sender))); + } + + private static WebhookPullRequest PullRequest(IReadOnlyList? labels = null) + { + var head = Create( + (nameof(WebhookPullRequestHead.Sha), "head-sha")); + + return Create( + (nameof(WebhookPullRequest.Number), 1L), + (nameof(WebhookPullRequest.Title), "Ready PR"), + (nameof(WebhookPullRequest.Body), ""), + (nameof(WebhookPullRequest.Draft), false), + (nameof(WebhookPullRequest.Head), head), + (nameof(WebhookPullRequest.User), User("contributor")), + (nameof(WebhookPullRequest.Labels), Labels(labels))); + } + + private static WebhookRepository Repository() + { + return Create( + (nameof(WebhookRepository.Name), "repo"), + (nameof(WebhookRepository.Owner), User("owner"))); + } + + private static WebhookUser User(string login) + { + return Create( + (nameof(WebhookUser.Login), login)); + } + + private static IReadOnlyList Labels(IReadOnlyList? names) + { + return names?.Select(Label).ToArray() ?? Array.Empty(); + } + + private static WebhookLabel Label(string name) + { + return Create( + (nameof(WebhookLabel.Name), name)); + } + + private static T Create(params (string PropertyName, object? Value)[] properties) + where T : class + { + var instance = (T)System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(typeof(T)); + + foreach (var (propertyName, value) in properties) + { + typeof(T).GetProperty(propertyName)!.SetValue(instance, value); + } + + return instance; + } + + private sealed class TestGitHubWebhookProcessor : GitHubWebhookProcessor + { + public TestGitHubWebhookProcessor(IGitHubService github, IConfiguration config) + : base(github, config) + { + } + + public ValueTask ProcessPullRequestAsync(PullRequestEvent payload, PullRequestAction action) + { + return ProcessPullRequestWebhookAsync(null!, payload, action); + } + + public ValueTask ProcessIssueCommentAsync(IssueCommentEvent payload, IssueCommentAction action) + { + return ProcessIssueCommentWebhookAsync(null!, payload, action); + } + + public ValueTask ProcessPullRequestReviewAsync(WebhookPullRequestReviewEvent payload, PullRequestReviewAction action) + { + return ProcessPullRequestReviewWebhookAsync(null!, payload, action); + } + } + + private sealed class FakeGitHubService : IGitHubService + { + public List LabelOperations { get; } = new(); + + public OctokitPullRequest PullRequest { get; set; } = new(); + + public HashSet TeamMembers { get; } = new(StringComparer.OrdinalIgnoreCase) + { + "captain" + }; + + public Task> GetPullRequestFilesAsync( + string owner, string repo, int prNumber) + { + return Task.FromResult>(Array.Empty()); + } + + public Task GetPullRequestAuthorEmailAsync( + string owner, string repo, int prNumber) + { + return Task.FromResult(null); + } + + public Task GetFileContentAsync( + string owner, string repo, string path, string sha) + { + return Task.FromResult(""); + } + + public Task AddLabelsAsync( + string owner, string repo, int issueNumber, params string[] labels) + { + LabelOperations.AddRange(labels.Select(label => $"add:{label}")); + return Task.CompletedTask; + } + + public Task RemoveLabelAsync( + string owner, string repo, int issueNumber, string label) + { + LabelOperations.Add($"remove:{label}"); + return Task.CompletedTask; + } + + public Task> GetTeamMembersAsync( + string owner, string teamName) + { + return Task.FromResult(TeamMembers); + } + + public Task GetPullRequestAsync( + string owner, string repo, int prNumber) + { + return Task.FromResult(PullRequest); + } + } +} diff --git a/tests/abaci-bot.Tests/abaci-bot.Tests.csproj b/tests/abaci-bot.Tests/abaci-bot.Tests.csproj new file mode 100644 index 0000000..33f5719 --- /dev/null +++ b/tests/abaci-bot.Tests/abaci-bot.Tests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + abaci_bot.Tests + enable + enable + + false + false + true + + + + + + + + + + + + + + + + + +