diff --git a/Annoy-o-Bot.AcceptanceTests/AcceptanceTest.cs b/Annoy-o-Bot.AcceptanceTests/AcceptanceTest.cs index 61cb0f3..94bd1a7 100644 --- a/Annoy-o-Bot.AcceptanceTests/AcceptanceTest.cs +++ b/Annoy-o-Bot.AcceptanceTests/AcceptanceTest.cs @@ -1,7 +1,8 @@ using System.Security.Cryptography; using System.Text; using Annoy_o_Bot.CosmosDB; -using Annoy_o_Bot.GitHub; +using Annoy_o_Bot.GitHub.Api; +using Annoy_o_Bot.GitHub.Callbacks; using Microsoft.AspNetCore.Http; using Microsoft.Azure.Cosmos; using Microsoft.Extensions.Configuration; @@ -67,11 +68,11 @@ protected async Task CreateDueReminders(IGitHubApi gitHubApi) await timeoutHandler.Run(null!, reminders, container); } - protected static HttpRequest CreateCallbackHttpRequest(CallbackModel callback) + protected static HttpRequest CreateCallbackHttpRequest(GitPushCallbackModel gitPushCallback) { var httpContext = new DefaultHttpContext(); var request = httpContext.Request; - var messageContent = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(callback, Formatting.None)); + var messageContent = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(gitPushCallback, Formatting.None)); request.Body = new MemoryStream(messageContent); request.Headers.Add("X-GitHub-Event", "push"); request.Headers.Add("X-Hub-Signature-256", diff --git a/Annoy-o-Bot.AcceptanceTests/Fakes/CallbackModelHelper.cs b/Annoy-o-Bot.AcceptanceTests/Fakes/CallbackModelHelper.cs index 98902d9..91baee5 100644 --- a/Annoy-o-Bot.AcceptanceTests/Fakes/CallbackModelHelper.cs +++ b/Annoy-o-Bot.AcceptanceTests/Fakes/CallbackModelHelper.cs @@ -1,10 +1,12 @@ -namespace Annoy_o_Bot.AcceptanceTests.Fakes; +using Annoy_o_Bot.GitHub.Callbacks; + +namespace Annoy_o_Bot.AcceptanceTests.Fakes; class CallbackModelHelper { - public static CallbackModel.CommitModel CreateCommitModel(string? added = null, string? modified = null, string? removed = null) + public static GitPushCallbackModel.CommitModel CreateCommitModel(string? added = null, string? modified = null, string? removed = null) { - var commit = new CallbackModel.CommitModel + var commit = new GitPushCallbackModel.CommitModel { Id = Guid.NewGuid().ToString(), Added = added != null ? new[] { added } : Array.Empty(), diff --git a/Annoy-o-Bot.AcceptanceTests/Fakes/FakeGitHubApi.cs b/Annoy-o-Bot.AcceptanceTests/Fakes/FakeGitHubApi.cs index 8a5b80e..da41df1 100644 --- a/Annoy-o-Bot.AcceptanceTests/Fakes/FakeGitHubApi.cs +++ b/Annoy-o-Bot.AcceptanceTests/Fakes/FakeGitHubApi.cs @@ -1,4 +1,4 @@ -using Annoy_o_Bot.GitHub; +using Annoy_o_Bot.GitHub.Api; namespace Annoy_o_Bot.AcceptanceTests.Fakes; @@ -6,10 +6,6 @@ class FakeGitHubApi : IGitHubApi { private Dictionary<(long, long), IGitHubRepository> registeredRepos = new(); - public FakeGitHubApi() - { - } - public FakeGitHubRepository CreateNewRepository() { var repository = new FakeGitHubRepository(Random.Shared.NextInt64(), Random.Shared.NextInt64()); diff --git a/Annoy-o-Bot.AcceptanceTests/Fakes/FakeGitHubInstallation.cs b/Annoy-o-Bot.AcceptanceTests/Fakes/FakeGitHubInstallation.cs index 6fda632..89f3fa9 100644 --- a/Annoy-o-Bot.AcceptanceTests/Fakes/FakeGitHubInstallation.cs +++ b/Annoy-o-Bot.AcceptanceTests/Fakes/FakeGitHubInstallation.cs @@ -1,4 +1,4 @@ -using Annoy_o_Bot.GitHub; +using Annoy_o_Bot.GitHub.Api; namespace Annoy_o_Bot.AcceptanceTests.Fakes; diff --git a/Annoy-o-Bot.AcceptanceTests/Fakes/FakeGitHubRepository.cs b/Annoy-o-Bot.AcceptanceTests/Fakes/FakeGitHubRepository.cs index d985caa..a801a11 100644 --- a/Annoy-o-Bot.AcceptanceTests/Fakes/FakeGitHubRepository.cs +++ b/Annoy-o-Bot.AcceptanceTests/Fakes/FakeGitHubRepository.cs @@ -1,4 +1,4 @@ -using Annoy_o_Bot.GitHub; +using Annoy_o_Bot.GitHub.Api; using Octokit; namespace Annoy_o_Bot.AcceptanceTests.Fakes; diff --git a/Annoy-o-Bot.AcceptanceTests/Fakes/RepoHelperExtensions.cs b/Annoy-o-Bot.AcceptanceTests/Fakes/RepoHelperExtensions.cs index 76e7f5b..7787e63 100644 --- a/Annoy-o-Bot.AcceptanceTests/Fakes/RepoHelperExtensions.cs +++ b/Annoy-o-Bot.AcceptanceTests/Fakes/RepoHelperExtensions.cs @@ -1,5 +1,5 @@ -using Newtonsoft.Json; -using Octokit; +using Annoy_o_Bot.GitHub.Callbacks; +using Newtonsoft.Json; namespace Annoy_o_Bot.AcceptanceTests.Fakes; @@ -7,7 +7,7 @@ static class RepoHelperExtensions { private const string DefaultBranch = "main"; - public static CallbackModel CommitNewReminder(this FakeGitHubRepository repo, ReminderDefinition reminderDefinition, + public static GitPushCallbackModel CommitNewReminder(this FakeGitHubRepository repo, ReminderDefinition reminderDefinition, string? branch = null) { var filename = Guid.NewGuid().ToString("N"); @@ -18,13 +18,13 @@ public static CallbackModel CommitNewReminder(this FakeGitHubRepository repo, Re return repo.Commit(commit, branch); } - public static CallbackModel Commit(this FakeGitHubRepository repo, CallbackModel.CommitModel commit, + public static GitPushCallbackModel Commit(this FakeGitHubRepository repo, GitPushCallbackModel.CommitModel commit, string? branch = null) { - return new CallbackModel + return new GitPushCallbackModel { - Installation = new CallbackModel.InstallationModel() { Id = repo.InstallationId }, - Repository = new CallbackModel.RepositoryModel() { Id = repo.RepositoryId, DefaultBranch = DefaultBranch }, + Installation = new GitPushCallbackModel.InstallationModel() { Id = repo.InstallationId }, + Repository = new GitPushCallbackModel.RepositoryModel() { Id = repo.RepositoryId, DefaultBranch = DefaultBranch }, Ref = $"refs/heads/{branch ?? DefaultBranch}", HeadCommit = commit, Commits = new []{ commit } diff --git a/Annoy-o-Bot.AcceptanceTests/When_callback_type_not_push.cs b/Annoy-o-Bot.AcceptanceTests/When_callback_type_not_push.cs index 8b6c723..c979abd 100644 --- a/Annoy-o-Bot.AcceptanceTests/When_callback_type_not_push.cs +++ b/Annoy-o-Bot.AcceptanceTests/When_callback_type_not_push.cs @@ -1,5 +1,4 @@ using Annoy_o_Bot.AcceptanceTests.Fakes; -using Annoy_o_Bot.GitHub; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging.Abstractions; using Xunit; diff --git a/Annoy-o-Bot.AcceptanceTests/When_deleting_reminder_on_default_branch.cs b/Annoy-o-Bot.AcceptanceTests/When_deleting_reminder_on_default_branch.cs index 4d091af..7dd25fb 100644 --- a/Annoy-o-Bot.AcceptanceTests/When_deleting_reminder_on_default_branch.cs +++ b/Annoy-o-Bot.AcceptanceTests/When_deleting_reminder_on_default_branch.cs @@ -1,6 +1,4 @@ -using System.Text.Json; -using Annoy_o_Bot.AcceptanceTests.Fakes; -using Annoy_o_Bot.CosmosDB; +using Annoy_o_Bot.AcceptanceTests.Fakes; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging.Abstractions; using Xunit; diff --git a/Annoy-o-Bot.AcceptanceTests/When_detecting_missing_reminder.cs b/Annoy-o-Bot.AcceptanceTests/When_detecting_missing_reminder.cs index 64fac97..09795ec 100644 --- a/Annoy-o-Bot.AcceptanceTests/When_detecting_missing_reminder.cs +++ b/Annoy-o-Bot.AcceptanceTests/When_detecting_missing_reminder.cs @@ -1,6 +1,5 @@ using Annoy_o_Bot.AcceptanceTests.Fakes; using Annoy_o_Bot.CosmosDB; -using Annoy_o_Bot.CosmosDB.Tests; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging.Abstractions; using Xunit; diff --git a/Annoy-o-Bot.AcceptanceTests/When_request_signature_does_not_match.cs b/Annoy-o-Bot.AcceptanceTests/When_request_signature_does_not_match.cs index 4fec36f..faa1cab 100644 --- a/Annoy-o-Bot.AcceptanceTests/When_request_signature_does_not_match.cs +++ b/Annoy-o-Bot.AcceptanceTests/When_request_signature_does_not_match.cs @@ -9,19 +9,20 @@ public class When_request_signature_does_not_match : AcceptanceTest [Fact] public async Task Should_return_error() { + const string InvalidSignature = "sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; var gitHubApi = new FakeGitHubApi(); var repository = gitHubApi.CreateNewRepository(); var request = CreateCallbackHttpRequest(repository.Commit(CallbackModelHelper.CreateCommitModel())); var requestHash = request.Headers["X-Hub-Signature-256"]; - request.Headers["X-Hub-Signature-256"] = "1234567890"; // this is not the incorrect but the expected signature in this test + request.Headers["X-Hub-Signature-256"] = InvalidSignature; // this is not the incorrect but the expected signature in this test var handler = new CallbackHandler(gitHubApi, configurationBuilder.Build(), NullLogger.Instance); var exception = await Assert.ThrowsAnyAsync(() => handler.Run(request, container)); Assert.Contains( - $"Computed request payload signature ('{requestHash}') does not match provided signature ('{request.Headers["X-Hub-Signature-256"]}')", + $"Computed request payload signature ('{requestHash}') does not match provided signature ('{InvalidSignature}')", exception.Message); } diff --git a/Annoy-o-Bot.AcceptanceTests/When_updating_reminder_not_stored.cs b/Annoy-o-Bot.AcceptanceTests/When_updating_reminder_not_stored.cs index 9881732..d3f8987 100644 --- a/Annoy-o-Bot.AcceptanceTests/When_updating_reminder_not_stored.cs +++ b/Annoy-o-Bot.AcceptanceTests/When_updating_reminder_not_stored.cs @@ -1,5 +1,6 @@ using Annoy_o_Bot.AcceptanceTests.Fakes; using Annoy_o_Bot.CosmosDB; +using Annoy_o_Bot.GitHub.Callbacks; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -24,7 +25,7 @@ public async Task Should_create_reminder_in_database() }; var createCallback = appInstallation.CommitNewReminder(updatedReminder); - var updateCommit = new CallbackModel.CommitModel + var updateCommit = new GitPushCallbackModel.CommitModel { Id = Guid.NewGuid().ToString(), Modified = diff --git a/Annoy-o-Bot.AcceptanceTests/When_updating_reminder_on_default_branch.cs b/Annoy-o-Bot.AcceptanceTests/When_updating_reminder_on_default_branch.cs index caabb4a..cbbbfc6 100644 --- a/Annoy-o-Bot.AcceptanceTests/When_updating_reminder_on_default_branch.cs +++ b/Annoy-o-Bot.AcceptanceTests/When_updating_reminder_on_default_branch.cs @@ -1,6 +1,6 @@ using System.Text.Json; using Annoy_o_Bot.AcceptanceTests.Fakes; -using Annoy_o_Bot.CosmosDB; +using Annoy_o_Bot.GitHub.Callbacks; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -37,7 +37,7 @@ public async Task Should_update_reminder_in_database() }; appInstallation.AddFileContent(createCallback.Commits[0].Added[0], JsonSerializer.Serialize(updatedReminder)); - var updateCommit = new CallbackModel.CommitModel + var updateCommit = new GitPushCallbackModel.CommitModel { Id = Guid.NewGuid().ToString(), Modified = new[] diff --git a/Annoy-o-Bot.Tests/Annoy-o-Bot.Tests.csproj b/Annoy-o-Bot.Tests/Annoy-o-Bot.Tests.csproj index 488c0fd..c11f8a2 100644 --- a/Annoy-o-Bot.Tests/Annoy-o-Bot.Tests.csproj +++ b/Annoy-o-Bot.Tests/Annoy-o-Bot.Tests.csproj @@ -2,7 +2,7 @@ net8.0 - Annoy_o_Bot.Tests + Annoy_o_Bot false diff --git a/Annoy-o-Bot.Tests/FileChangesTests.cs b/Annoy-o-Bot.Tests/FileChangesTests.cs index de88239..7f14438 100644 --- a/Annoy-o-Bot.Tests/FileChangesTests.cs +++ b/Annoy-o-Bot.Tests/FileChangesTests.cs @@ -1,14 +1,14 @@ -namespace Annoy_o_Bot.Tests -{ - using Annoy_o_Bot.GitHub; - using Xunit; +using Annoy_o_Bot.GitHub.Callbacks; +using Xunit; +namespace Annoy_o_Bot +{ public class FileChangesTests { [Fact] public void Should_return_empty_changes_when_no_commits() { - var result = CommitParser.GetChanges(new CallbackModel.CommitModel[0]); + var result = CommitParser.GetChanges(new GitPushCallbackModel.CommitModel[0]); Assert.Empty(result.New); Assert.Empty(result.Deleted); @@ -20,19 +20,19 @@ public void Should_aggregate_changes_from_all_commits() { var commitModel = new[] { - new CallbackModel.CommitModel + new GitPushCallbackModel.CommitModel { Added = new []{ "a1" }, Modified = new []{ "m1" }, Removed = new [] { "r1"} }, - new CallbackModel.CommitModel + new GitPushCallbackModel.CommitModel { Added = new []{ "a2" }, Modified = new []{ "m2" }, Removed = new [] { "r2"} }, - new CallbackModel.CommitModel + new GitPushCallbackModel.CommitModel { Added = new []{ "a3" }, Modified = new []{ "m3" }, @@ -58,11 +58,11 @@ public void Should_handle_multiple_updates() { var commitModel = new[] { - new CallbackModel.CommitModel + new GitPushCallbackModel.CommitModel { Modified = new []{ "file1" }, }, - new CallbackModel.CommitModel + new GitPushCallbackModel.CommitModel { Modified = new [] { "file1"} } @@ -79,11 +79,11 @@ public void Should_handle_update_delete() { var commitModel = new[] { - new CallbackModel.CommitModel + new GitPushCallbackModel.CommitModel { Modified = new []{ "file1" }, }, - new CallbackModel.CommitModel + new GitPushCallbackModel.CommitModel { Removed = new [] { "file1"} } @@ -101,15 +101,15 @@ public void Should_handle_new_delete() { var commitModel = new[] { - new CallbackModel.CommitModel + new GitPushCallbackModel.CommitModel { Added = new []{ "file1" }, }, - new CallbackModel.CommitModel + new GitPushCallbackModel.CommitModel { Modified = new []{ "file1" }, }, - new CallbackModel.CommitModel + new GitPushCallbackModel.CommitModel { Removed = new [] { "file1"} } @@ -128,15 +128,15 @@ public void Should_handle_new_update() { var commitModel = new[] { - new CallbackModel.CommitModel + new GitPushCallbackModel.CommitModel { Added = new []{ "file1" }, }, - new CallbackModel.CommitModel + new GitPushCallbackModel.CommitModel { Modified = new []{ "file1" }, }, - new CallbackModel.CommitModel + new GitPushCallbackModel.CommitModel { Modified = new [] { "file1"} } @@ -155,15 +155,15 @@ public void Should_handle_delete_new() { var commitModel = new[] { - new CallbackModel.CommitModel + new GitPushCallbackModel.CommitModel { Modified = new []{ "file1" }, }, - new CallbackModel.CommitModel + new GitPushCallbackModel.CommitModel { Removed = new []{ "file1" }, }, - new CallbackModel.CommitModel + new GitPushCallbackModel.CommitModel { Added = new [] { "file1"} } diff --git a/Annoy-o-Bot.Tests/GitHub/Callbacks/GitPushCallbackModelTests.cs b/Annoy-o-Bot.Tests/GitHub/Callbacks/GitPushCallbackModelTests.cs new file mode 100644 index 0000000..90c1e1b --- /dev/null +++ b/Annoy-o-Bot.Tests/GitHub/Callbacks/GitPushCallbackModelTests.cs @@ -0,0 +1,69 @@ +using System.IO; +using Newtonsoft.Json; +using Xunit; + +namespace Annoy_o_Bot.GitHub.Callbacks; + +public class GitPushCallbackModelTests +{ + [Fact] + public void NewFileAdded() + { + var result = JsonConvert.DeserializeObject(File.ReadAllText("requests/fileAdded.json")); + + Assert.Equal("refs/heads/master", result.Ref); + + Assert.Equal(7498230L, result.Installation.Id); + + Assert.Equal(179716425L, result.Repository.Id); + Assert.Equal("master", result.Repository.DefaultBranch); + Assert.Equal("TitleTest", result.Repository.Name); + + var commit = Assert.Single(result.Commits); + Assert.Equal("ba7c6f17f5beaafc603eca52b864356848865fec", commit.Id); + var addedFile = Assert.Single(commit.Added); + Assert.Equal(".reminder/testReminder.json", addedFile); + Assert.Equal("Create trigger4.json", commit.Message); + + Assert.Equal("timbussmann", result.Pusher.Name); + } + + [Fact] + public void MultiCommit() + { + var result = JsonConvert.DeserializeObject(File.ReadAllText("requests/multiCommitFileHistory.json")); + + Assert.Equal(4, result.Commits.Length); + + Assert.Equal(".reminder/newFile.json", Assert.Single(result.Commits[0].Added)); + Assert.Empty(result.Commits[0].Modified); + Assert.Empty(result.Commits[0].Removed); + Assert.Equal("Create newFile.json", result.Commits[0].Message); + + Assert.Equal(".reminder/newFile.json", Assert.Single(result.Commits[1].Modified)); + Assert.Empty(result.Commits[1].Added); + Assert.Empty(result.Commits[1].Removed); + Assert.Equal("Update newFile.json", result.Commits[1].Message); + + Assert.Equal(".reminder/newFile.json", Assert.Single(result.Commits[2].Removed)); + Assert.Empty(result.Commits[2].Added); + Assert.Empty(result.Commits[2].Modified); + Assert.Equal("Delete newFile.json", result.Commits[2].Message); + + Assert.Empty(result.Commits[3].Added); + Assert.Empty(result.Commits[3].Modified); + Assert.Empty(result.Commits[3].Removed); + Assert.Equal("Merge pull request #15 from timbussmann/file-ops-history\n\nCreate newFile.json", result.Commits[3].Message); + + Assert.Equal("cb1ec97f51657c2718ab4e0b1d0bf2656aeb3127", result.HeadCommit.Id); + } + + [Fact] + public void BranchDeleted() + { + var result = JsonConvert.DeserializeObject(File.ReadAllText("requests/branchDeleted.json")); + + Assert.Null(result.HeadCommit); + Assert.Empty(result.Commits); + } +} \ No newline at end of file diff --git a/Annoy-o-Bot.Tests/GitHub/GitHubHelperTests.cs b/Annoy-o-Bot.Tests/GitHub/GitHubHelperTests.cs new file mode 100644 index 0000000..148c00f --- /dev/null +++ b/Annoy-o-Bot.Tests/GitHub/GitHubHelperTests.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Logging.Abstractions; +using System; +using System.Threading.Tasks; +using Xunit; +using Annoy_o_Bot.GitHub.Api; +using Annoy_o_Bot.GitHub.Callbacks; + +namespace Annoy_o_Bot.Tests +{ + public class GitHubHelperTests + { + [Theory] + [InlineData("46F335537C051512C7554148D3683D98DEE8843E2E919A21065E0BD5FD09CDA5")] + [InlineData("46f335537c051512c7554148d3683d98dee8843e2e919a21065e0bd5fd09cda5")] + public async Task Should_verify_valid_body(string hash) + { + var gitHubApi = new GitHubApi(NullLoggerFactory.Instance); + + await GitHubCallbackRequest.ValidateSignature("Hello World!", "secretkey", hash); + } + + [Fact] + public void Should_throw_on_invalid_body() + { + var gitHubApi = new GitHubApi(NullLoggerFactory.Instance); + + Assert.ThrowsAsync(() => GitHubCallbackRequest.ValidateSignature("Hello Wörld!", "secretkey", "B0D3E5FBD7B71A4539E27257AF48C677E8CAD2F803C2CC87C3164CD4254AFF79")); + } + } +} \ No newline at end of file diff --git a/Annoy-o-Bot.Tests/GitHub/IssueMappingTests.cs b/Annoy-o-Bot.Tests/GitHub/IssueMappingTests.cs index 2627b36..ca069cc 100644 --- a/Annoy-o-Bot.Tests/GitHub/IssueMappingTests.cs +++ b/Annoy-o-Bot.Tests/GitHub/IssueMappingTests.cs @@ -1,4 +1,4 @@ -using Annoy_o_Bot.GitHub; +using Annoy_o_Bot.GitHub.Api; using Xunit; namespace Annoy_o_Bot.Tests.GitHub; diff --git a/Annoy-o-Bot.Tests/GitHubHelperTests.cs b/Annoy-o-Bot.Tests/GitHubHelperTests.cs deleted file mode 100644 index d2d9680..0000000 --- a/Annoy-o-Bot.Tests/GitHubHelperTests.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Microsoft.Extensions.Logging.Abstractions; -using System; -using System.IO; -using System.Text; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Xunit; -using Annoy_o_Bot.GitHub; - -namespace Annoy_o_Bot.Tests -{ - public class GitHubHelperTests - { - [Theory] - [InlineData("46F335537C051512C7554148D3683D98DEE8843E2E919A21065E0BD5FD09CDA5")] - [InlineData("46f335537c051512c7554148d3683d98dee8843e2e919a21065e0bd5fd09cda5")] - public async Task Should_verify_valid_body(string hash) - { - var httpContext = new DefaultHttpContext(); - var request = httpContext.Request; - request.Headers.Add("X-Hub-Signature-256", $"sha256={hash}"); - request.Body = new MemoryStream(Encoding.UTF8.GetBytes("Hello World!")); - - await GitHubHelper.ValidateRequest(request, "secretkey", NullLogger.Instance); - } - - [Fact] - public void Should_throw_on_invalid_body() - { - var httpContext = new DefaultHttpContext(); - var request = httpContext.Request; - request.Headers.Add("X-Hub-Signature-256", "sha256=B0D3E5FBD7B71A4539E27257AF48C677E8CAD2F803C2CC87C3164CD4254AFF79"); - request.Body = new MemoryStream(Encoding.UTF8.GetBytes("Hello Wörld!")); - - Assert.ThrowsAsync(() => GitHubHelper.ValidateRequest(request, "somekey", NullLogger.Instance)); - } - } -} \ No newline at end of file diff --git a/Annoy-o-Bot.Tests/Parser/JsonReminderParserTests.cs b/Annoy-o-Bot.Tests/Parser/JsonReminderParserTests.cs index 3561f75..f1b4614 100644 --- a/Annoy-o-Bot.Tests/Parser/JsonReminderParserTests.cs +++ b/Annoy-o-Bot.Tests/Parser/JsonReminderParserTests.cs @@ -1,8 +1,6 @@ using System; using Annoy_o_Bot.Parser; using Newtonsoft.Json; -using Newtonsoft.Json.Converters; -using Newtonsoft.Json.Serialization; using Xunit; namespace Annoy_o_Bot.Tests.Parser diff --git a/Annoy-o-Bot.Tests/ReminderFilterTests.cs b/Annoy-o-Bot.Tests/ReminderFilterTests.cs index 848eb87..97ae2b9 100644 --- a/Annoy-o-Bot.Tests/ReminderFilterTests.cs +++ b/Annoy-o-Bot.Tests/ReminderFilterTests.cs @@ -1,4 +1,4 @@ -using Annoy_o_Bot.GitHub; +using Annoy_o_Bot.GitHub.Callbacks; namespace Annoy_o_Bot.Tests { diff --git a/Annoy-o-Bot.Tests/RequestParserTests.cs b/Annoy-o-Bot.Tests/RequestParserTests.cs deleted file mode 100644 index 8804ef4..0000000 --- a/Annoy-o-Bot.Tests/RequestParserTests.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.IO; -using Annoy_o_Bot.GitHub; -using Xunit; - -namespace Annoy_o_Bot.Tests -{ - public class RequestParserTests - { - [Fact] - public void NewFileAdded() - { - var result = RequestParser.ParseJson(File.ReadAllText("requests/fileAdded.json")); - - Assert.Equal("refs/heads/master", result.Ref); - - Assert.Equal(7498230L, result.Installation.Id); - - Assert.Equal(179716425L, result.Repository.Id); - Assert.Equal("master", result.Repository.DefaultBranch); - Assert.Equal("TitleTest", result.Repository.Name); - - var commit = Assert.Single(result.Commits); - Assert.Equal("ba7c6f17f5beaafc603eca52b864356848865fec", commit.Id); - var addedFile = Assert.Single(commit.Added); - Assert.Equal(".reminder/testReminder.json", addedFile); - Assert.Equal("Create trigger4.json", commit.Message); - - Assert.Equal("timbussmann", result.Pusher.Name); - } - - [Fact] - public void MultiCommit() - { - var result = RequestParser.ParseJson(File.ReadAllText("requests/multiCommitFileHistory.json")); - - Assert.Equal(4, result.Commits.Length); - - Assert.Equal(".reminder/newFile.json", Assert.Single(result.Commits[0].Added)); - Assert.Empty(result.Commits[0].Modified); - Assert.Empty(result.Commits[0].Removed); - Assert.Equal("Create newFile.json", result.Commits[0].Message); - - Assert.Equal(".reminder/newFile.json", Assert.Single(result.Commits[1].Modified)); - Assert.Empty(result.Commits[1].Added); - Assert.Empty(result.Commits[1].Removed); - Assert.Equal("Update newFile.json", result.Commits[1].Message); - - Assert.Equal(".reminder/newFile.json", Assert.Single(result.Commits[2].Removed)); - Assert.Empty(result.Commits[2].Added); - Assert.Empty(result.Commits[2].Modified); - Assert.Equal("Delete newFile.json", result.Commits[2].Message); - - Assert.Empty(result.Commits[3].Added); - Assert.Empty(result.Commits[3].Modified); - Assert.Empty(result.Commits[3].Removed); - Assert.Equal("Merge pull request #15 from timbussmann/file-ops-history\n\nCreate newFile.json", result.Commits[3].Message); - - Assert.Equal("cb1ec97f51657c2718ab4e0b1d0bf2656aeb3127", result.HeadCommit.Id); - } - - [Fact] - public void BranchDeleted() - { - var result = RequestParser.ParseJson(File.ReadAllText("requests/branchDeleted.json")); - - Assert.Null(result.HeadCommit); - Assert.Empty(result.Commits); - } - } -} \ No newline at end of file diff --git a/Annoy-o-Bot/CallbackHandler.cs b/Annoy-o-Bot/CallbackHandler.cs index 2e72290..48357b1 100644 --- a/Annoy-o-Bot/CallbackHandler.cs +++ b/Annoy-o-Bot/CallbackHandler.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Threading.Tasks; using Annoy_o_Bot.CosmosDB; @@ -9,11 +8,11 @@ using Microsoft.Extensions.Logging; using Octokit; using Annoy_o_Bot.Parser; -using Annoy_o_Bot.GitHub; +using Annoy_o_Bot.GitHub.Api; +using Annoy_o_Bot.GitHub.Callbacks; using Microsoft.Azure.Cosmos; using Microsoft.Extensions.Configuration; using Microsoft.Azure.Functions.Worker; -using Microsoft.Extensions.Primitives; namespace Annoy_o_Bot { @@ -30,25 +29,20 @@ public async Task Run( Container cosmosContainer) { var cosmosWrapper = new CosmosClientWrapper(cosmosContainer); - await GitHubHelper.ValidateRequest(req, configuration.GetValue("WebhookSecret") ?? throw new Exception("Missing 'WebhookSecret' env var"), log); - if (!IsGitCommitCallback(req)) - { - return new OkResult(); - } + var secret = configuration.GetValue("WebhookSecret") ?? + throw new Exception("Missing 'WebhookSecret' setting to validate GitHub callbacks."); + var commitModel = await GitHubCallbackRequest.Validate(req, secret, log); - var requestObject = await ParseRequest(req, log); - var githubClient = await gitHubApi.GetRepository(requestObject.Installation.Id, requestObject.Repository.Id); - - if (requestObject.HeadCommit == null) + if (commitModel?.HeadCommit == null) { // no commits on push (e.g. branch delete) return new OkResult(); } + + log.LogInformation($"Handling changes made to branch '{commitModel.Repository.Name}{commitModel.Ref}' by head-commit '{commitModel.HeadCommit.Id}'."); - log.LogInformation($"Handling changes made to branch '{requestObject.Repository.Name}{requestObject.Ref}' by head-commit '{requestObject.HeadCommit.Id}'."); - - var commitsToConsider = requestObject.Commits; + var commitsToConsider = commitModel.Commits; if (commitsToConsider.LastOrDefault()?.Message?.StartsWith("Merge ") ?? false) { // if the last commit is a merge commit, ignore other commits as the merge commits contains all the relevant changes @@ -56,22 +50,23 @@ public async Task Run( commitsToConsider = [commitsToConsider.Last()]; } + var githubRepository = await gitHubApi.GetRepository(commitModel.Installation.Id, commitModel.Repository.Id); var fileChanges = CommitParser.GetChanges(commitsToConsider); var reminderChanges = ReminderFilter.FilterReminders(fileChanges); - if (requestObject.Ref.EndsWith($"/{requestObject.Repository.DefaultBranch}")) + if (commitModel.IsDefaultBranch()) { - await ApplyReminderDefinitions(reminderChanges, requestObject, githubClient, cosmosWrapper, fileChanges); + await ApplyReminderDefinitions(reminderChanges, commitModel, githubRepository, cosmosWrapper, fileChanges); } else { - await ValidateReminderDefinitions(reminderChanges, requestObject, githubClient); + await ValidateReminderDefinitions(reminderChanges, commitModel, githubRepository); } return new OkResult(); } - async Task ApplyReminderDefinitions(FileChanges reminderChanges, CallbackModel requestObject, + async Task ApplyReminderDefinitions(FileChanges reminderChanges, GitPushCallbackModel requestObject, IGitHubRepository githubClient, CosmosClientWrapper cosmosWrapper, FileChanges fileChanges) { var newReminders = await LoadReminders(reminderChanges.New, requestObject, githubClient); @@ -108,7 +103,7 @@ await githubClient.CreateComment(requestObject.HeadCommit.Id, await DeleteRemovedReminders(fileChanges.Deleted, cosmosWrapper, requestObject, githubClient); } - async Task ValidateReminderDefinitions(FileChanges reminderChanges, CallbackModel requestObject, + async Task ValidateReminderDefinitions(FileChanges reminderChanges, GitPushCallbackModel requestObject, IGitHubRepository githubClient) { List<(string, ReminderDefinition)> newReminders; @@ -119,47 +114,28 @@ async Task ValidateReminderDefinitions(FileChanges reminderChanges, CallbackMode } catch (Exception e) { - await TryCreateCheckRun(githubClient, requestObject.Repository.Id, - new NewCheckRun("annoy-o-bot", requestObject.HeadCommit.Id) - { - Status = CheckStatus.Completed, - Conclusion = CheckConclusion.Failure, - Output = new NewCheckRunOutput( - "Invalid reminder definition", - "The provided reminder seems to be invalid or incorrect." + e.Message) - }, log); + await githubClient.CreateCheckRun(new NewCheckRun("annoy-o-bot", requestObject.HeadCommit.Id) + { + Status = CheckStatus.Completed, + Conclusion = CheckConclusion.Failure, + Output = new NewCheckRunOutput( + "Invalid reminder definition", + "The provided reminder seems to be invalid or incorrect." + e.Message) + }); throw; } if (newReminders.Any()) { - await TryCreateCheckRun(githubClient, requestObject.Repository.Id, - new NewCheckRun("annoy-o-bot", requestObject.HeadCommit.Id) - { - Status = CheckStatus.Completed, - Conclusion = CheckConclusion.Success - }, log); - } - } - - bool IsGitCommitCallback(HttpRequest req) - { - if (!req.Headers.TryGetValue("X-GitHub-Event", out var callbackEvent) || callbackEvent != "push") - { - // Check for known callback types that we don't care - if (callbackEvent != "check_suite") // ignore check_suite events + await githubClient.CreateCheckRun(new NewCheckRun("annoy-o-bot", requestObject.HeadCommit.Id) { - // record unknown callback types to further analyze them - log.LogWarning($"Non-push callback. 'X-GitHub-Event': '{callbackEvent}'"); - } - - return false; + Status = CheckStatus.Completed, + Conclusion = CheckConclusion.Success + }); } - - return true; } - async Task CreateNewReminder(CosmosClientWrapper cosmosWrapper, CallbackModel requestObject, ReminderDefinition reminderDefinition, string fileName, + async Task CreateNewReminder(CosmosClientWrapper cosmosWrapper, GitPushCallbackModel requestObject, ReminderDefinition reminderDefinition, string fileName, IGitHubRepository githubClient) { var reminderDocument = ReminderDocument.New( @@ -173,37 +149,7 @@ await githubClient.CreateComment(requestObject.HeadCommit.Id, $"Created reminder '{reminderDefinition.Title}' for {reminderDefinition.Date:D}"); } - private static async Task ParseRequest(HttpRequest req, ILogger log) - { - CallbackModel requestObject; - try - { - var requestBody = await new StreamReader(req.Body).ReadToEndAsync(); - requestObject = RequestParser.ParseJson(requestBody); - } - catch (Exception e) - { - log.LogError(e, "Error at parsing callback input"); - throw; - } - - return requestObject; - } - - private static async Task TryCreateCheckRun(IGitHubRepository installationClient, long repositoryId, NewCheckRun checkRun, ILogger logger) - { - // Ignore check run failures for now. Check run permissions were added later, so users might not have granted permissions to add check runs. - try - { - await installationClient.CreateCheckRun(checkRun); - } - catch (Exception e) - { - logger.LogWarning(e, $"Failed to create check run for repository {repositoryId}."); - } - } - - static async Task> LoadReminders(ICollection filePaths, CallbackModel requestObject, IGitHubRepository installationClient) + static async Task> LoadReminders(ICollection filePaths, GitPushCallbackModel requestObject, IGitHubRepository installationClient) { var results = new List<(string, ReminderDefinition)>(filePaths.Count); // potentially lower but never higher than number of files foreach (var filePath in filePaths) @@ -223,7 +169,7 @@ private static async Task TryCreateCheckRun(IGitHubRepository installationClient return results; } - async Task DeleteRemovedReminders(ICollection deletedFiles, CosmosClientWrapper cosmosWrapper, CallbackModel requestObject, IGitHubRepository client) + async Task DeleteRemovedReminders(ICollection deletedFiles, CosmosClientWrapper cosmosWrapper, GitPushCallbackModel requestObject, IGitHubRepository client) { foreach (var deletedReminder in deletedFiles) { diff --git a/Annoy-o-Bot/CallbackModel.cs b/Annoy-o-Bot/CallbackModel.cs deleted file mode 100644 index 8d8c4f3..0000000 --- a/Annoy-o-Bot/CallbackModel.cs +++ /dev/null @@ -1,45 +0,0 @@ -#nullable disable - -using System; -using Newtonsoft.Json; - -namespace Annoy_o_Bot -{ - public class CallbackModel - { - public InstallationModel Installation { get; set; } - public RepositoryModel Repository { get; set; } - public CommitModel[] Commits { get; set; } - public string Ref { get; set; } - [JsonProperty("head_commit")] - public CommitModel HeadCommit { get; set; } - public PusherModel Pusher { get; set; } - - public class CommitModel - { - public string Id { get; set; } - public string Message { get; set; } - public string[] Added { get; set; } = Array.Empty(); - public string[] Modified { get; set; } = Array.Empty(); - public string[] Removed { get; set; } = Array.Empty(); - } - - public class InstallationModel - { - public long Id { get; set; } - } - - public class RepositoryModel - { - public long Id { get; set; } - [JsonProperty("default_branch")] - public string DefaultBranch { get; set; } - public string Name { get; set; } - } - - public class PusherModel - { - public string Name { get; set; } - } - } -} \ No newline at end of file diff --git a/Annoy-o-Bot/CosmosDB/ICosmosClientWrapper.cs b/Annoy-o-Bot/CosmosDB/ICosmosClientWrapper.cs index 2ef0dd8..46ca3e6 100644 --- a/Annoy-o-Bot/CosmosDB/ICosmosClientWrapper.cs +++ b/Annoy-o-Bot/CosmosDB/ICosmosClientWrapper.cs @@ -1,7 +1,6 @@  using System.Collections.Generic; using System.Threading.Tasks; -using Microsoft.Azure.Cosmos; namespace Annoy_o_Bot.CosmosDB; diff --git a/Annoy-o-Bot/DetectMissingReminders.cs b/Annoy-o-Bot/DetectMissingReminders.cs index 13f1bf1..25930d9 100644 --- a/Annoy-o-Bot/DetectMissingReminders.cs +++ b/Annoy-o-Bot/DetectMissingReminders.cs @@ -2,7 +2,7 @@ using System.Linq; using System.Threading.Tasks; using Annoy_o_Bot.CosmosDB; -using Annoy_o_Bot.GitHub; +using Annoy_o_Bot.GitHub.Api; using Annoy_o_Bot.Parser; using Microsoft.AspNetCore.Http; using Microsoft.Azure.Cosmos; diff --git a/Annoy-o-Bot/GitHub/Api/GitHubApi.cs b/Annoy-o-Bot/GitHub/Api/GitHubApi.cs new file mode 100644 index 0000000..0e2efd8 --- /dev/null +++ b/Annoy-o-Bot/GitHub/Api/GitHubApi.cs @@ -0,0 +1,65 @@ +using System; +using System.Threading.Tasks; +using GitHubJwt; +using Microsoft.Extensions.Logging; +using Octokit; + +namespace Annoy_o_Bot.GitHub.Api; + +public class GitHubApi : IGitHubApi +{ + ILoggerFactory loggerFactory; + ILogger logger; + + public GitHubApi(ILoggerFactory loggerFactory) + { + this.loggerFactory = loggerFactory; + this.logger = loggerFactory.CreateLogger(); + } + + public async Task GetInstallation(long installationId) + { + var installationClient = await GetInstallationClient(installationId); + return new GitHubInstallation(installationClient, installationId, loggerFactory); + } + + public async Task GetRepository(long installationId, long repositoryId) + { + var installationClient = await GetInstallationClient(installationId); + return new GitHubRepository(installationClient, repositoryId, loggerFactory.CreateLogger()); + } + + static async Task GetInstallationClient(long installationId) + { + // Use GitHubJwt library to create the GitHubApp Jwt Token using our private certificate PEM file + var appIntegrationId = Convert.ToInt32(Environment.GetEnvironmentVariable("GitHubAppId")); + var environmentVariablePrivateKeySource = new EnvironmentVariablePrivateKeySource("PrivateKey"); + var generator = new GitHubJwtFactory( + environmentVariablePrivateKeySource, + new GitHubJwtFactoryOptions + { + AppIntegrationId = appIntegrationId, + ExpirationSeconds = 600 // 10 minutes is the maximum time allowed + } + ); + var jwtToken = generator.CreateEncodedJwtToken(); + + var productHeaderValue = Environment.GetEnvironmentVariable("GitHubProductHeader"); + // Use the JWT as a Bearer token + var appClient = new GitHubClient(new ProductHeaderValue(productHeaderValue)) + { + Credentials = new Credentials(jwtToken, AuthenticationType.Bearer) + }; + // Get the current authenticated GitHubApp + var app = await appClient.GitHubApps.GetCurrent(); + // Create an Installation token for Insallation Id + var response = await appClient.GitHubApps.CreateInstallationToken(installationId); + + // Create a new GitHubClient using the installation token as authentication + var installationClient = new GitHubClient(new ProductHeaderValue(productHeaderValue)) + { + Credentials = new Credentials(response.Token) + }; + return installationClient; + } +} \ No newline at end of file diff --git a/Annoy-o-Bot/GitHub/GitHubInstallation.cs b/Annoy-o-Bot/GitHub/Api/GitHubInstallation.cs similarity index 95% rename from Annoy-o-Bot/GitHub/GitHubInstallation.cs rename to Annoy-o-Bot/GitHub/Api/GitHubInstallation.cs index 45e4d85..08268df 100644 --- a/Annoy-o-Bot/GitHub/GitHubInstallation.cs +++ b/Annoy-o-Bot/GitHub/Api/GitHubInstallation.cs @@ -2,7 +2,7 @@ using Microsoft.Extensions.Logging; using Octokit; -namespace Annoy_o_Bot.GitHub; +namespace Annoy_o_Bot.GitHub.Api; class GitHubInstallation : IGitHubInstallation { diff --git a/Annoy-o-Bot/GitHub/GitHubRepository.cs b/Annoy-o-Bot/GitHub/Api/GitHubRepository.cs similarity index 54% rename from Annoy-o-Bot/GitHub/GitHubRepository.cs rename to Annoy-o-Bot/GitHub/Api/GitHubRepository.cs index a375c08..0b22691 100644 --- a/Annoy-o-Bot/GitHub/GitHubRepository.cs +++ b/Annoy-o-Bot/GitHub/Api/GitHubRepository.cs @@ -5,26 +5,16 @@ using Microsoft.Extensions.Logging; using Octokit; -namespace Annoy_o_Bot.GitHub; +namespace Annoy_o_Bot.GitHub.Api; -public class GitHubRepository : IGitHubRepository +public class GitHubRepository(GitHubClient gitHubClient, long repositoryId, ILogger logger) + : IGitHubRepository { - readonly GitHubClient installationClient; - readonly long repositoryId; - readonly ILogger logger; - - public GitHubRepository(GitHubClient gitHubClient, long repositoryId, ILogger logger) - { - this.repositoryId = repositoryId; - this.logger = logger; - installationClient = gitHubClient; - } - public async Task> ReadAllRemindersFromDefaultBranch() { try { - var reminders = await installationClient.Repository.Content.GetAllContents(repositoryId, ".reminders"); + var reminders = await gitHubClient.Repository.Content.GetAllContents(repositoryId, ".reminders"); return reminders.Select(content => content.Path).ToList(); } catch (NotFoundException e) @@ -40,13 +30,13 @@ public async Task ReadFileContent(string filePath, string? branchReferen if (branchReference == null) { - contents = await installationClient.Repository.Content.GetAllContentsByRef( + contents = await gitHubClient.Repository.Content.GetAllContentsByRef( repositoryId, filePath); } else { - contents = await installationClient.Repository.Content.GetAllContentsByRef( + contents = await gitHubClient.Repository.Content.GetAllContentsByRef( repositoryId, filePath, branchReference); @@ -55,14 +45,22 @@ public async Task ReadFileContent(string filePath, string? branchReferen return contents.First().Content; } - public Task CreateCheckRun(NewCheckRun checkRun) + public async Task CreateCheckRun(NewCheckRun checkRun) { - return installationClient.Check.Run.Create(repositoryId, checkRun); + try + { + await gitHubClient.Check.Run.Create(repositoryId, checkRun); + } + catch (Exception e) + { + // Ignore check run failures for now. Check run permissions were added later, so users might not have granted permissions to add check runs. + logger.LogWarning(e, $"Failed to create check run for repository {repositoryId}."); + } } public Task CreateComment(string commitId, string comment) { - return installationClient.Repository.Comment.Create( + return gitHubClient.Repository.Comment.Create( repositoryId, commitId, new NewCommitComment(comment)); @@ -70,6 +68,6 @@ public Task CreateComment(string commitId, string comment) public Task CreateIssue(NewIssue issue) { - return installationClient.Issue.Create(repositoryId, issue); + return gitHubClient.Issue.Create(repositoryId, issue); } } \ No newline at end of file diff --git a/Annoy-o-Bot/GitHub/IGitHubApi.cs b/Annoy-o-Bot/GitHub/Api/IGitHubApi.cs similarity index 86% rename from Annoy-o-Bot/GitHub/IGitHubApi.cs rename to Annoy-o-Bot/GitHub/Api/IGitHubApi.cs index 26996a3..8c9208b 100644 --- a/Annoy-o-Bot/GitHub/IGitHubApi.cs +++ b/Annoy-o-Bot/GitHub/Api/IGitHubApi.cs @@ -1,6 +1,6 @@ using System.Threading.Tasks; -namespace Annoy_o_Bot.GitHub; +namespace Annoy_o_Bot.GitHub.Api; public interface IGitHubApi { diff --git a/Annoy-o-Bot/GitHub/IGitHubInstallation.cs b/Annoy-o-Bot/GitHub/Api/IGitHubInstallation.cs similarity index 80% rename from Annoy-o-Bot/GitHub/IGitHubInstallation.cs rename to Annoy-o-Bot/GitHub/Api/IGitHubInstallation.cs index b990cc0..6a8bbd1 100644 --- a/Annoy-o-Bot/GitHub/IGitHubInstallation.cs +++ b/Annoy-o-Bot/GitHub/Api/IGitHubInstallation.cs @@ -1,6 +1,6 @@ using System.Threading.Tasks; -namespace Annoy_o_Bot.GitHub; +namespace Annoy_o_Bot.GitHub.Api; public interface IGitHubInstallation { diff --git a/Annoy-o-Bot/GitHub/IGitHubRepository.cs b/Annoy-o-Bot/GitHub/Api/IGitHubRepository.cs similarity index 92% rename from Annoy-o-Bot/GitHub/IGitHubRepository.cs rename to Annoy-o-Bot/GitHub/Api/IGitHubRepository.cs index 314398a..7f0979c 100644 --- a/Annoy-o-Bot/GitHub/IGitHubRepository.cs +++ b/Annoy-o-Bot/GitHub/Api/IGitHubRepository.cs @@ -2,7 +2,7 @@ using System.Threading.Tasks; using Octokit; -namespace Annoy_o_Bot.GitHub; +namespace Annoy_o_Bot.GitHub.Api; public interface IGitHubRepository { diff --git a/Annoy-o-Bot/GitHub/ReminderMappingExtensions.cs b/Annoy-o-Bot/GitHub/Api/ReminderMappingExtensions.cs similarity index 91% rename from Annoy-o-Bot/GitHub/ReminderMappingExtensions.cs rename to Annoy-o-Bot/GitHub/Api/ReminderMappingExtensions.cs index 6887f72..bfc1783 100644 --- a/Annoy-o-Bot/GitHub/ReminderMappingExtensions.cs +++ b/Annoy-o-Bot/GitHub/Api/ReminderMappingExtensions.cs @@ -1,8 +1,8 @@ -using Octokit; +using System; using System.Linq; -using System; +using Octokit; -namespace Annoy_o_Bot.GitHub; +namespace Annoy_o_Bot.GitHub.Api; public static class ReminderMappingExtensions { diff --git a/Annoy-o-Bot/GitHub/Callbacks/CallbackModel.cs b/Annoy-o-Bot/GitHub/Callbacks/CallbackModel.cs new file mode 100644 index 0000000..22bb6d1 --- /dev/null +++ b/Annoy-o-Bot/GitHub/Callbacks/CallbackModel.cs @@ -0,0 +1,46 @@ +#nullable disable + +using System; +using Newtonsoft.Json; + +namespace Annoy_o_Bot.GitHub.Callbacks; + +public class GitPushCallbackModel +{ + public InstallationModel Installation { get; set; } + public RepositoryModel Repository { get; set; } + public CommitModel[] Commits { get; set; } + public string Ref { get; set; } + + [JsonProperty("head_commit")] + public CommitModel HeadCommit { get; set; } + public PusherModel Pusher { get; set; } + + public class CommitModel + { + public string Id { get; set; } + public string Message { get; set; } + public string[] Added { get; set; } = Array.Empty(); + public string[] Modified { get; set; } = Array.Empty(); + public string[] Removed { get; set; } = Array.Empty(); + } + + public class InstallationModel + { + public long Id { get; set; } + } + + public class RepositoryModel + { + public long Id { get; set; } + + [JsonProperty("default_branch")] + public string DefaultBranch { get; set; } + public string Name { get; set; } + } + + public class PusherModel + { + public string Name { get; set; } + } +} \ No newline at end of file diff --git a/Annoy-o-Bot/GitHub/Callbacks/CallbackModelExtensions.cs b/Annoy-o-Bot/GitHub/Callbacks/CallbackModelExtensions.cs new file mode 100644 index 0000000..1ce9e9c --- /dev/null +++ b/Annoy-o-Bot/GitHub/Callbacks/CallbackModelExtensions.cs @@ -0,0 +1,9 @@ +namespace Annoy_o_Bot.GitHub.Callbacks; + +public static class CallbackModelExtensions +{ + public static bool IsDefaultBranch(this GitPushCallbackModel gitPushCallbackModel) + { + return gitPushCallbackModel.Ref.EndsWith($"/{gitPushCallbackModel.Repository.DefaultBranch}"); + } +} \ No newline at end of file diff --git a/Annoy-o-Bot/GitHub/CommitParser.cs b/Annoy-o-Bot/GitHub/Callbacks/CommitParser.cs similarity index 79% rename from Annoy-o-Bot/GitHub/CommitParser.cs rename to Annoy-o-Bot/GitHub/Callbacks/CommitParser.cs index a6dc2c4..e1319f8 100644 --- a/Annoy-o-Bot/GitHub/CommitParser.cs +++ b/Annoy-o-Bot/GitHub/Callbacks/CommitParser.cs @@ -1,17 +1,17 @@ using System.Collections.Generic; -namespace Annoy_o_Bot.GitHub +namespace Annoy_o_Bot.GitHub.Callbacks { public class FileChanges { - public HashSet New { get; set; } = new HashSet(); - public HashSet Updated { get; set; } = new HashSet(); - public HashSet Deleted { get; set; } = new HashSet(); + public HashSet New { get; set; } = []; + public HashSet Updated { get; set; } = []; + public HashSet Deleted { get; set; } = []; } public class CommitParser { - public static FileChanges GetChanges(CallbackModel.CommitModel[] commits) + public static FileChanges GetChanges(GitPushCallbackModel.CommitModel[] commits) { var changes = new FileChanges(); diff --git a/Annoy-o-Bot/GitHub/Callbacks/GitHubCallbackRequest.cs b/Annoy-o-Bot/GitHub/Callbacks/GitHubCallbackRequest.cs new file mode 100644 index 0000000..0324ac9 --- /dev/null +++ b/Annoy-o-Bot/GitHub/Callbacks/GitHubCallbackRequest.cs @@ -0,0 +1,64 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; + +namespace Annoy_o_Bot.GitHub.Callbacks; + +public class GitHubCallbackRequest +{ + public static async Task Validate(HttpRequest callbackRequest, string gitHubSecret, ILogger log) + { + if (!IsGitCommitCallback(callbackRequest, log)) + { + return null; + } + + if (!callbackRequest.Headers.TryGetValue("X-Hub-Signature-256", out var sha256SignatureHeaderValue)) + { + throw new Exception("Incoming callback request does not contain a 'X-Hub-Signature-256' header"); + } + + var requestBody = await new StreamReader(callbackRequest.Body).ReadToEndAsync(); + + await ValidateSignature(requestBody, gitHubSecret, sha256SignatureHeaderValue.ToString().Replace("sha256=", "")); + + return JsonConvert.DeserializeObject(requestBody); + } + + static bool IsGitCommitCallback(HttpRequest callbackRequest, ILogger log) + { + if (!callbackRequest.Headers.TryGetValue("X-GitHub-Event", out var callbackEvent) || callbackEvent != "push") + { + // Check for known callback types that we don't care + if (callbackEvent != "check_suite") // ignore check_suite events + { + // record unknown callback types to further analyze them + log.LogWarning($"Non-push callback. 'X-GitHub-Event': '{callbackEvent}'"); + } + + return false; + } + + return true; + } + + public static async Task ValidateSignature(string signedText, string secret, string sha256Signature) + { + var hmacsha256 = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); + + using var memoryStream = new MemoryStream(Encoding.UTF8.GetBytes(signedText)); + + var hash = await hmacsha256.ComputeHashAsync(memoryStream); + var hashString = Convert.ToHexString(hash); + + if (!string.Equals(sha256Signature, hashString, StringComparison.OrdinalIgnoreCase)) + { + throw new Exception($"Computed request payload signature ('sha256={hashString}') does not match provided signature ('sha256={sha256Signature}'). Signed text: '{signedText}'"); + } + } +} \ No newline at end of file diff --git a/Annoy-o-Bot/GitHub/GitHubApi.cs b/Annoy-o-Bot/GitHub/GitHubApi.cs deleted file mode 100644 index 7a00166..0000000 --- a/Annoy-o-Bot/GitHub/GitHubApi.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Octokit; - -namespace Annoy_o_Bot.GitHub; - -public class GitHubApi : IGitHubApi -{ - ILoggerFactory loggerFactory; - - public GitHubApi(ILoggerFactory loggerFactory) - { - this.loggerFactory = loggerFactory; - } - - public async Task GetInstallation(long installationId) - { - var installationClient = await GetInstallationClient(installationId); - return new GitHubInstallation(installationClient, installationId, loggerFactory); - } - - public async Task GetRepository(long installationId, long repositoryId) - { - var installationClient = await GetInstallationClient(installationId); - return new GitHubRepository(installationClient, repositoryId, loggerFactory.CreateLogger()); - } - - static Task GetInstallationClient(long installationId) => GitHubHelper.GetInstallationClient(installationId); -} \ No newline at end of file diff --git a/Annoy-o-Bot/GitHub/GitHubHelper.cs b/Annoy-o-Bot/GitHub/GitHubHelper.cs deleted file mode 100644 index 218335a..0000000 --- a/Annoy-o-Bot/GitHub/GitHubHelper.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System; -using System.IO; -using System.Runtime.InteropServices; -using System.Text; -using System.Threading.Tasks; -using GitHubJwt; -using Microsoft.Extensions.Logging; -using Octokit; - -namespace Annoy_o_Bot.GitHub -{ - using System.Security.Cryptography; - using Microsoft.AspNetCore.Http; - - public class GitHubHelper - { - /// - /// Validates whether the request is indeed coming from GitHub using the webhook secret. - /// - public static async Task ValidateRequest(HttpRequest request, string secret, ILogger logger) - { - if (!request.Headers.TryGetValue("X-Hub-Signature-256", out var sha256Signature)) - { - throw new Exception("Incoming callback request does not contain a 'X-Hub-Signature-256' header"); - } - - var hmacsha256 = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); - - // enable buffering so we can reset the request body stream position - // otherwise this throws a System.NotSupportedException when running in Azure Functions - request.EnableBuffering(); - - var hash = await hmacsha256.ComputeHashAsync(request.Body); - request.Body.Position = 0; - var hashString = $"sha256={Convert.ToHexString(hash)}"; - - if (!string.Equals(sha256Signature, hashString, StringComparison.OrdinalIgnoreCase)) - { - logger.LogWarning($"Validation mismatch. {Environment.MachineName}, {Environment.OSVersion}, {Environment.Version}, {RuntimeInformation.RuntimeIdentifier}, {RuntimeInformation.OSArchitecture}, {RuntimeInformation.OSDescription}, {RuntimeInformation.FrameworkDescription}, {RuntimeInformation.ProcessArchitecture}"); - var hmacsha1 = new HMACSHA1(Encoding.UTF8.GetBytes(secret)); - if (ValidateRequestSha1(request, hmacsha1)) - { - logger.LogWarning("Failed SHA256 validation but passed SHA1 check."); - return; - } - - var exception = new Exception($"Computed request payload signature ('{hashString}') does not match provided signature ('{sha256Signature}')"); - logger.LogWarning(new StreamReader(request.Body).ReadToEnd()); - throw exception; - } - } - - public static bool ValidateRequestSha1(HttpRequest request, HMACSHA1 sha1) - { - if (!request.Headers.TryGetValue("X-Hub-Signature", out var sha1Signature)) - { - return false; - } - - var hash = sha1?.ComputeHash(request.Body) ?? Array.Empty(); - request.Body.Position = 0; - var hexString = Convert.ToHexString(hash); - var hashString = $"sha1={hexString}"; - - return string.Equals(sha1Signature, hashString, StringComparison.OrdinalIgnoreCase); - } - - public static async Task GetInstallationClient(long installationId) - { - // Use GitHubJwt library to create the GitHubApp Jwt Token using our private certificate PEM file - var appIntegrationId = Convert.ToInt32(Environment.GetEnvironmentVariable("GitHubAppId")); - var environmentVariablePrivateKeySource = new EnvironmentVariablePrivateKeySource("PrivateKey"); - var generator = new GitHubJwtFactory( - environmentVariablePrivateKeySource, - new GitHubJwtFactoryOptions - { - AppIntegrationId = appIntegrationId, - ExpirationSeconds = 600 // 10 minutes is the maximum time allowed - } - ); - var jwtToken = generator.CreateEncodedJwtToken(); - - var productHeaderValue = Environment.GetEnvironmentVariable("GitHubProductHeader"); - // Use the JWT as a Bearer token - var appClient = new GitHubClient(new ProductHeaderValue(productHeaderValue)) - { - Credentials = new Credentials(jwtToken, AuthenticationType.Bearer) - }; - // Get the current authenticated GitHubApp - var app = await appClient.GitHubApps.GetCurrent(); - // Create an Installation token for Insallation Id - var response = await appClient.GitHubApps.CreateInstallationToken(installationId); - - // Create a new GitHubClient using the installation token as authentication - var installationClient = new GitHubClient(new ProductHeaderValue(productHeaderValue)) - { - Credentials = new Credentials(response.Token) - }; - return installationClient; - } - } -} \ No newline at end of file diff --git a/Annoy-o-Bot/GitHub/RequestParser.cs b/Annoy-o-Bot/GitHub/RequestParser.cs deleted file mode 100644 index 6cef641..0000000 --- a/Annoy-o-Bot/GitHub/RequestParser.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Newtonsoft.Json; - -namespace Annoy_o_Bot.GitHub -{ - public class RequestParser - { - public static CallbackModel ParseJson(string requestBody) - { - CallbackModel requestObject = JsonConvert.DeserializeObject(requestBody); - return requestObject; - } - } -} \ No newline at end of file diff --git a/Annoy-o-Bot/Program.cs b/Annoy-o-Bot/Program.cs index 6362f20..4e52b78 100644 --- a/Annoy-o-Bot/Program.cs +++ b/Annoy-o-Bot/Program.cs @@ -1,4 +1,4 @@ -using Annoy_o_Bot.GitHub; +using Annoy_o_Bot.GitHub.Api; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; diff --git a/Annoy-o-Bot/ReminderFilter.cs b/Annoy-o-Bot/ReminderFilter.cs index 91e1736..85f31cb 100644 --- a/Annoy-o-Bot/ReminderFilter.cs +++ b/Annoy-o-Bot/ReminderFilter.cs @@ -1,4 +1,4 @@ -using Annoy_o_Bot.GitHub; +using Annoy_o_Bot.GitHub.Callbacks; namespace Annoy_o_Bot { diff --git a/Annoy-o-Bot/TimeoutFunction.cs b/Annoy-o-Bot/TimeoutFunction.cs index eed3822..31059ca 100644 --- a/Annoy-o-Bot/TimeoutFunction.cs +++ b/Annoy-o-Bot/TimeoutFunction.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using Annoy_o_Bot.CosmosDB; -using Annoy_o_Bot.GitHub; +using Annoy_o_Bot.GitHub.Api; using Microsoft.Azure.Cosmos; using Microsoft.Extensions.Logging; using Octokit; @@ -43,12 +43,10 @@ public async Task Run( { reminderDocument.CalculateNextReminder(now); - var newIssue = reminderDocument.Reminder.ToGitHubIssue(); - log.LogDebug($"Scheduling next due date for reminder {reminderDocument.Id} for {reminderDocument.NextReminder}"); var repository = await gitHubApi.GetRepository(reminderDocument.InstallationId, reminderDocument.RepositoryId); - var issue = await repository.CreateIssue(newIssue); + var issue = await repository.CreateIssue(reminderDocument.Reminder.ToGitHubIssue()); log.LogInformation($"Created reminder issue #{issue.Number} based on reminder {reminderDocument.Id}");