Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -64,4 +65,3 @@ jobs:
with:
files: ./dist/abaci-bot
generate_release_notes: true

1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,4 @@ appsettings.json
.idea
.vs/
.vscode/
Properties/launchSettings.json
2 changes: 1 addition & 1 deletion Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
return;
}

builder.Services.AddSingleton(new GitHubService(
builder.Services.AddSingleton<IGitHubService>(new GitHubService(
builder.Configuration.GetValue<int>("GitHubApp:AppId"),
builder.Configuration["GitHubApp:PrivateKey"]!,
builder.Configuration.GetValue<long>("GitHubApp:InstallationId")
Expand Down
12 changes: 12 additions & 0 deletions Services/GitHubLabels.cs
Original file line number Diff line number Diff line change
@@ -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";
}
4 changes: 2 additions & 2 deletions Services/GitHubService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

namespace abaci_bot.Services;

public class GitHubService
public class GitHubService : IGitHubService
{
private readonly int _appId;
private readonly string _privateKey;
Expand Down Expand Up @@ -91,7 +91,7 @@ public async Task<IReadOnlyList<PullRequestFile>> GetPullRequestFilesAsync(
return await _client.PullRequest.Files(owner, repo, prNumber);
}

public async Task<string> GetPullRequestAuthorEmailAsync(
public async Task<string?> GetPullRequestAuthorEmailAsync(
string owner, string repo, int prNumber)
{
await EnsureAuthenticatedAsync();
Expand Down
147 changes: 109 additions & 38 deletions Services/GitHubWebhookProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)
Expand All @@ -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);
Expand All @@ -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);
}

}
Expand All @@ -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);
}
}
}

Expand All @@ -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);
}
}
}

Expand All @@ -147,40 +171,50 @@ 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);
}
}
}
}

// 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)
Expand Down Expand Up @@ -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<string> GetLabelNames(IEnumerable<Octokit.Webhooks.Models.Label>? labels)
{
return labels?
.Select(label => label.Name)
.Where(name => !string.IsNullOrWhiteSpace(name))
.ToHashSet(StringComparer.OrdinalIgnoreCase) ?? new HashSet<string>(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)
Expand Down
27 changes: 27 additions & 0 deletions Services/IGitHubService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using Octokit;

namespace abaci_bot.Services;

public interface IGitHubService
{
Task<IReadOnlyList<PullRequestFile>> GetPullRequestFilesAsync(
string owner, string repo, int prNumber);

Task<string?> GetPullRequestAuthorEmailAsync(
string owner, string repo, int prNumber);

Task<string> 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<HashSet<string>> GetTeamMembersAsync(
string owner, string teamName);

Task<PullRequest> GetPullRequestAsync(
string owner, string repo, int prNumber);
}
7 changes: 7 additions & 0 deletions abaci-bot.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,11 @@
<PackageReference Include="Octokit.Webhooks.AspNetCore" Version="4.0.0" />
</ItemGroup>

<ItemGroup>
<Compile Remove="tests\**" />
<Content Remove="tests\**" />
<EmbeddedResource Remove="tests\**" />
<None Remove="tests\**" />
</ItemGroup>

</Project>
10 changes: 9 additions & 1 deletion abaci-bot.sln
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Loading
Loading