├── .gitignore ├── Miro ├── .dockerignore ├── Models │ ├── Github │ │ ├── Entities │ │ │ ├── Base.cs │ │ │ ├── Issue.cs │ │ │ ├── Branch.cs │ │ │ ├── Team.cs │ │ │ ├── User.cs │ │ │ ├── Comment.cs │ │ │ ├── Head.cs │ │ │ ├── Repository.cs │ │ │ ├── Review.cs │ │ │ ├── CheckSuite.cs │ │ │ ├── PullRequest.cs │ │ │ └── FileContent.cs │ │ ├── Responses │ │ │ ├── ReviewsResponse.cs │ │ │ ├── UpdatePrResponse.cs │ │ │ ├── RequiredChecksResponse.cs │ │ │ ├── MergePrResponse.cs │ │ │ ├── ReviewRequestsResponse.cs │ │ │ └── WebhookResponse.cs │ │ ├── RequestPayloads │ │ │ ├── CreateCommentPayload.cs │ │ │ ├── PullRequestCheckStatus.cs │ │ │ ├── UpdateBranchPayload.cs │ │ │ ├── MergePrPayload.cs │ │ │ └── UpdateStatusCheckPayload.cs │ │ └── IncomingEvents │ │ │ ├── PushEvent.cs │ │ │ ├── CheckSuiteEvent.cs │ │ │ ├── IssueComentEvent.cs │ │ │ ├── PullRequestEvent.cs │ │ │ ├── PullRequestReviewEvent.cs │ │ │ └── StatusEvent.cs │ ├── Validation │ │ └── ValidationError.cs │ ├── Checks │ │ ├── CheckStatus.cs │ │ └── CheckList.cs │ ├── Merge │ │ └── MergeRequest.cs │ └── MiroConfig │ │ └── RepoConfig.cs ├── Services │ ├── Github │ │ ├── EventHandlers │ │ │ ├── IWebhookEventHandler.cs │ │ │ ├── PullRequestReviewEventHandler.cs │ │ │ ├── StatusEventHandler.cs │ │ │ ├── PushEventHandler.cs │ │ │ └── IssueCommentEventHandler.cs │ │ ├── PullRequestMismatchException.cs │ │ ├── ReviewsRetriever.cs │ │ ├── FileRetriever.cs │ │ ├── PrDeleter.cs │ │ ├── PrStatusChecks.cs │ │ ├── PrUpdater.cs │ │ ├── PrMerger.cs │ │ ├── GithubHttpClient.cs │ │ └── CommentCreator.cs │ ├── Logger │ │ └── LoggerExt.cs │ ├── Checks │ │ ├── ChecksRepository.cs │ │ ├── ChecksRetriever.cs │ │ ├── MiroMergeCheck.cs │ │ └── ChecksManager.cs │ ├── Utils │ │ └── DictionaryExt.cs │ ├── MiroConfig │ │ ├── RepoConfigRepository.cs │ │ └── RepoConfigManager.cs │ ├── Auth │ │ ├── ApiKeyMiddleware.cs │ │ └── InstallationTokenStore.cs │ ├── MiroStats │ │ └── MiroStatsProvider.cs │ ├── Comments │ │ └── CommentsConsts.cs │ └── Merge │ │ ├── MergeabilityValidator.cs │ │ ├── MergeOperations.cs │ │ └── MergeRequestsRepository.cs ├── Dockerfile ├── .gitignore ├── appsettings.Development.json ├── appsettings.json ├── Docs │ └── event_handlers.md ├── Program.cs ├── Controllers │ ├── IsAliveController.cs │ └── GithubWebhookController.cs ├── Properties │ └── launchSettings.json ├── Miro.csproj └── Startup.cs ├── Miro.Tests ├── DummyConfigYamls │ ├── strategyAll.yml │ ├── defaultBranchOther.yml │ ├── strategyNone.yml │ ├── strategyOldest.yml │ ├── default.yml │ ├── defaultBranchSomeDefaultBranch.yml │ ├── invalid.yml │ └── quiet.yml ├── MockGithubApi │ ├── .dockerignore │ ├── package.json │ ├── Dockerfile │ ├── .gitignore │ └── index.js ├── obj │ ├── Debug │ │ └── netcoreapp2.1 │ │ │ ├── Miro.Tests.csproj.CopyComplete │ │ │ ├── Miro.Tests.AssemblyInfoInputs.cache │ │ │ ├── Miro.Tests.csproj.CoreCompileInputs.cache │ │ │ ├── Miro.Tests.dll │ │ │ ├── Miro.Tests.pdb │ │ │ ├── Miro.Tests.Program.cs │ │ │ ├── Miro.Tests.assets.cache │ │ │ ├── Miro.Tests.csprojAssemblyReference.cache │ │ │ ├── .NETCoreApp,Version=v2.1.AssemblyAttributes.cs │ │ │ ├── project.razor.json │ │ │ ├── Miro.Tests.AssemblyInfo.cs │ │ │ └── Miro.Tests.csproj.FileListAbsolute.txt │ ├── Miro.Tests.csproj.nuget.cache │ ├── Miro.Tests.csproj.nuget.g.targets │ ├── Miro.Tests.csproj.nuget.g.props │ └── Miro.Tests.csproj.nuget.dgspec.json ├── Helpers │ ├── CheckStatus.cs │ ├── Consts.cs │ ├── MockMergeGithubCallHelper.cs │ ├── MockRequiredChecksGithubCallHelper.cs │ ├── MockReviewGithubCallHelper.cs │ ├── MockRepoConfigGithubCallHelper.cs │ ├── GithubUrlHelpers.cs │ ├── CheckListsCollection.cs │ ├── RepoConfigurationCollection.cs │ ├── WebhookRequestSender.cs │ ├── MockCommentGithubCallHelper.cs │ ├── MergeRequestsCollection.cs │ └── GithubApiMock.cs ├── DummyEvents │ ├── Push.json │ ├── IssueComment.json │ ├── Status.json │ ├── PullRequest.json │ └── ReviewPullRequest.json ├── Dockerfile ├── Miro.Tests.csproj ├── docker-compose.yml ├── IssueInfoCommentEventProcessingTests.cs ├── IssueCancelCommentEventProcessingTests.cs ├── ReviewEventProcessingTests.cs └── RepoConfigurationTests.cs ├── docs ├── only_miro_can_merge_img.png ├── CONTRIBUTING.md └── DEPLOYING.md ├── SECURITY.md ├── LICENSE ├── Miro.sln └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ -------------------------------------------------------------------------------- /Miro/.dockerignore: -------------------------------------------------------------------------------- 1 | bin\ 2 | obj\ -------------------------------------------------------------------------------- /Miro.Tests/DummyConfigYamls/strategyAll.yml: -------------------------------------------------------------------------------- 1 | updateBranchStrategy: all -------------------------------------------------------------------------------- /Miro.Tests/MockGithubApi/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log -------------------------------------------------------------------------------- /Miro.Tests/obj/Debug/netcoreapp2.1/Miro.Tests.csproj.CopyComplete: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Miro.Tests/DummyConfigYamls/defaultBranchOther.yml: -------------------------------------------------------------------------------- 1 | defaultBranch: other -------------------------------------------------------------------------------- /Miro.Tests/DummyConfigYamls/strategyNone.yml: -------------------------------------------------------------------------------- 1 | updateBranchStrategy: none -------------------------------------------------------------------------------- /Miro.Tests/DummyConfigYamls/strategyOldest.yml: -------------------------------------------------------------------------------- 1 | updateBranchStrategy: oldest -------------------------------------------------------------------------------- /Miro.Tests/DummyConfigYamls/default.yml: -------------------------------------------------------------------------------- 1 | mergePolicy: whitelist-strict 2 | updateBranchStrategy: all -------------------------------------------------------------------------------- /Miro.Tests/DummyConfigYamls/defaultBranchSomeDefaultBranch.yml: -------------------------------------------------------------------------------- 1 | defaultBranch: some-default-branch -------------------------------------------------------------------------------- /Miro.Tests/DummyConfigYamls/invalid.yml: -------------------------------------------------------------------------------- 1 | updateBranchStrategy: something-bad 2 | mergePolicy: something-badder -------------------------------------------------------------------------------- /Miro.Tests/DummyConfigYamls/quiet.yml: -------------------------------------------------------------------------------- 1 | mergePolicy: whitelist-strict 2 | updateBranchStrategy: all 3 | quiet: true -------------------------------------------------------------------------------- /docs/only_miro_can_merge_img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soluto/Miro/HEAD/docs/only_miro_can_merge_img.png -------------------------------------------------------------------------------- /Miro.Tests/obj/Debug/netcoreapp2.1/Miro.Tests.AssemblyInfoInputs.cache: -------------------------------------------------------------------------------- 1 | d97df3e93ac78fea6ff08ac887ad0d148bc70d9f 2 | -------------------------------------------------------------------------------- /Miro.Tests/obj/Debug/netcoreapp2.1/Miro.Tests.csproj.CoreCompileInputs.cache: -------------------------------------------------------------------------------- 1 | b075ed513398b1d0c3920f5cb00f31955daa5955 2 | -------------------------------------------------------------------------------- /Miro.Tests/obj/Debug/netcoreapp2.1/Miro.Tests.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soluto/Miro/HEAD/Miro.Tests/obj/Debug/netcoreapp2.1/Miro.Tests.dll -------------------------------------------------------------------------------- /Miro.Tests/obj/Debug/netcoreapp2.1/Miro.Tests.pdb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soluto/Miro/HEAD/Miro.Tests/obj/Debug/netcoreapp2.1/Miro.Tests.pdb -------------------------------------------------------------------------------- /Miro.Tests/obj/Debug/netcoreapp2.1/Miro.Tests.Program.cs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soluto/Miro/HEAD/Miro.Tests/obj/Debug/netcoreapp2.1/Miro.Tests.Program.cs -------------------------------------------------------------------------------- /Miro.Tests/obj/Debug/netcoreapp2.1/Miro.Tests.assets.cache: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soluto/Miro/HEAD/Miro.Tests/obj/Debug/netcoreapp2.1/Miro.Tests.assets.cache -------------------------------------------------------------------------------- /Miro/Models/Github/Entities/Base.cs: -------------------------------------------------------------------------------- 1 | namespace Miro.Models.Github.Entities 2 | { 3 | public class Base 4 | { 5 | public string Ref { get; set; } 6 | } 7 | } -------------------------------------------------------------------------------- /Miro/Models/Github/Entities/Issue.cs: -------------------------------------------------------------------------------- 1 | namespace Miro.Models.Github.Entities 2 | { 3 | public class Issue 4 | { 5 | public int Number { get; set; } 6 | } 7 | } -------------------------------------------------------------------------------- /Miro/Models/Github/Entities/Branch.cs: -------------------------------------------------------------------------------- 1 | namespace Miro.Models.Github.Entities 2 | { 3 | public class Branch 4 | { 5 | public string Name { get; set; } 6 | } 7 | } -------------------------------------------------------------------------------- /Miro/Models/Validation/ValidationError.cs: -------------------------------------------------------------------------------- 1 | namespace Miro.Models.Validation 2 | { 3 | 4 | public class ValidationError 5 | { 6 | public string Error { get; set; } 7 | } 8 | } -------------------------------------------------------------------------------- /Miro.Tests/obj/Debug/netcoreapp2.1/Miro.Tests.csprojAssemblyReference.cache: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soluto/Miro/HEAD/Miro.Tests/obj/Debug/netcoreapp2.1/Miro.Tests.csprojAssemblyReference.cache -------------------------------------------------------------------------------- /Miro.Tests/obj/Miro.Tests.csproj.nuget.cache: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "dgSpecHash": "OaG6iESR/FC1zXGDj9RrqXiKMtl7hfNGXsbazLnrOGaZrkRIvTLO9aPOGYp13KUAqEfutMFPVGWPXiJGPYsOUw==", 4 | "success": true 5 | } -------------------------------------------------------------------------------- /Miro.Tests/Helpers/CheckStatus.cs: -------------------------------------------------------------------------------- 1 | namespace Miro.Tests.Helpers 2 | { 3 | public class CheckStatus 4 | { 5 | public string Name { get; set; } 6 | public string Status { get; set; } 7 | } 8 | } -------------------------------------------------------------------------------- /Miro/Models/Github/Entities/Team.cs: -------------------------------------------------------------------------------- 1 | namespace Miro.Models.Github.Entities 2 | { 3 | public class Team 4 | { 5 | public string Name { get; set; } 6 | public int Id { get; set; } 7 | } 8 | } -------------------------------------------------------------------------------- /Miro/Models/Github/Entities/User.cs: -------------------------------------------------------------------------------- 1 | namespace Miro.Models.Github.Entities 2 | { 3 | public class User 4 | { 5 | public string Login { get; set; } 6 | public int Id { get; set; } 7 | } 8 | } -------------------------------------------------------------------------------- /Miro/Models/Github/Entities/Comment.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Newtonsoft.Json; 3 | 4 | namespace Miro.Models.Github.Entities 5 | { 6 | public class Comment 7 | { 8 | public string Body { get; set; } 9 | } 10 | } -------------------------------------------------------------------------------- /Miro.Tests/DummyEvents/Push.json: -------------------------------------------------------------------------------- 1 | { 2 | "ref": "master", 3 | "repository": { 4 | "id": 135493233, 5 | "name": "TEST-REPO", 6 | "owner": { 7 | "login": "TEST-OWNER", 8 | "id": 21031067 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Miro/Models/Github/Entities/Head.cs: -------------------------------------------------------------------------------- 1 | namespace Miro.Models.Github.Entities 2 | { 3 | public class Head 4 | { 5 | public string Ref { get; set; } 6 | public string Sha { get; set; } 7 | public Repository Repo { get; set; } 8 | } 9 | } -------------------------------------------------------------------------------- /Miro/Models/Github/Entities/Repository.cs: -------------------------------------------------------------------------------- 1 | namespace Miro.Models.Github.Entities 2 | { 3 | public class Repository 4 | { 5 | public User Owner { get; set; } 6 | public string Name { get; set; } 7 | public bool Fork { get; set; } 8 | } 9 | } -------------------------------------------------------------------------------- /Miro.Tests/obj/Debug/netcoreapp2.1/.NETCoreApp,Version=v2.1.AssemblyAttributes.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using System.Reflection; 4 | [assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v2.1", FrameworkDisplayName = "")] 5 | -------------------------------------------------------------------------------- /Miro/Models/Github/Responses/ReviewsResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Miro.Models.Github.Entities; 3 | 4 | namespace Miro.Models.Github.Responses 5 | { 6 | class ReviewesResponse 7 | { 8 | public List Reviews { get; set; } 9 | } 10 | } -------------------------------------------------------------------------------- /Miro/Models/Github/Responses/UpdatePrResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Miro.Models.Github.Entities; 3 | 4 | namespace Miro.Models.Github.Responses 5 | { 6 | public class UpdatePrResponse 7 | { 8 | public string Message { get; set; } 9 | } 10 | } -------------------------------------------------------------------------------- /Miro/Models/Github/RequestPayloads/CreateCommentPayload.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | 3 | namespace Miro.Models.Github.RequestPayloads 4 | { 5 | public class CreateCommentPayload 6 | { 7 | [JsonProperty(PropertyName = "body")] 8 | public string Body; 9 | } 10 | } -------------------------------------------------------------------------------- /Miro.Tests/Helpers/Consts.cs: -------------------------------------------------------------------------------- 1 | namespace Miro.Tests.Helpers 2 | { 3 | public class Consts 4 | { 5 | public static string DEFAULT_BRANCH = "some-branch"; 6 | public static string TEST_CHECK_A = "some test a"; 7 | public static string TEST_CHECK_B = "some test b"; 8 | } 9 | } -------------------------------------------------------------------------------- /Miro/Models/Github/Responses/RequiredChecksResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Miro.Models.Github.Entities; 3 | 4 | namespace Miro.Models.Github.Responses 5 | { 6 | public class RequiredChecksResponse 7 | { 8 | public string[] Contexts {get; set;} 9 | } 10 | } -------------------------------------------------------------------------------- /Miro/Models/Github/Responses/MergePrResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Miro.Models.Github.Entities; 3 | 4 | namespace Miro.Models.Github.Responses 5 | { 6 | public class MergePrResponse 7 | { 8 | public bool Merged { get; set; } 9 | public string Message { get; set; } = "unknown"; 10 | } 11 | } -------------------------------------------------------------------------------- /Miro/Models/Checks/CheckStatus.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Miro.Models.Checks 4 | { 5 | public class CheckStatus 6 | { 7 | public string Name { get; set; } 8 | public string Status { get; set; } 9 | public DateTime UpdatedAt { get; set; } 10 | public string TargetUrl { get; set; } 11 | } 12 | } -------------------------------------------------------------------------------- /Miro.Tests/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM microsoft/dotnet:sdk 2 | WORKDIR /app 3 | 4 | 5 | # Copy csproj and restore as distinct layers 6 | COPY *.csproj ./ 7 | RUN dotnet restore 8 | 9 | # Copy everything else and build 10 | COPY . /app 11 | 12 | # Build runtime image 13 | WORKDIR /app 14 | CMD ["dotnet", "test", "--logger", "trx;LogFileName=test_results.trx"] -------------------------------------------------------------------------------- /Miro.Tests/MockGithubApi/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "MockGithubApi", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "body-parser": "^1.18.3", 8 | "escape-string-regexp": "^1.0.5", 9 | "express": "^4.16.3", 10 | "simple-fake-server": "^2.1.0", 11 | "uuid": "^3.3.2" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Miro/Models/Github/Responses/ReviewRequestsResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Miro.Models.Github.Entities; 3 | 4 | namespace Miro.Models.Github.Responses 5 | { 6 | public class RequestedReviewersResponse 7 | { 8 | public List Users { get; set; } = new List(); 9 | public List Teams { get; set; } = new List(); 10 | } 11 | } -------------------------------------------------------------------------------- /Miro/Models/Github/Entities/Review.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Newtonsoft.Json; 3 | 4 | namespace Miro.Models.Github.Entities 5 | { 6 | public class Review 7 | { 8 | public User User { get; set; } = new User(); 9 | public string State { get; set; } 10 | [JsonProperty(PropertyName = "submitted_at")] 11 | public DateTime SubmittedAt { get; set; } 12 | } 13 | } -------------------------------------------------------------------------------- /Miro/Models/Github/IncomingEvents/PushEvent.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Miro.Models.Github.Entities; 3 | using Newtonsoft.Json; 4 | 5 | namespace Miro.Models.Github.IncomingEvents 6 | { 7 | public class PushEvent 8 | { 9 | public string Ref { get; set; } 10 | public string After { get; set; } 11 | public Repository Repository { get; set; } 12 | } 13 | } -------------------------------------------------------------------------------- /Miro.Tests/DummyEvents/IssueComment.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "created", 3 | "issue": { 4 | "number": 2 5 | }, 6 | "comment": { 7 | "body": "Miro merge" 8 | }, 9 | "repository": { 10 | "name": "TEST-REPO", 11 | "owner": { 12 | "login": "TEST-OWNER", 13 | "id": 21031067 14 | } 15 | }, 16 | "sender": { 17 | "login": "TEST-OWNER", 18 | "id": 21031067 19 | } 20 | } -------------------------------------------------------------------------------- /Miro/Models/Github/RequestPayloads/PullRequestCheckStatus.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | 3 | namespace Miro.Models.Github.RequestPayloads 4 | { 5 | public class PullRequestCheckStatus 6 | { 7 | public string State { get; set; } 8 | public string Context { get; set; } 9 | 10 | [JsonProperty(PropertyName = "target_url")] 11 | public string TargetUrl { get; set; } 12 | } 13 | } -------------------------------------------------------------------------------- /Miro/Services/Github/EventHandlers/IWebhookEventHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Miro.Models.Github.Responses; 3 | 4 | namespace Miro.Services.Github.EventHandlers 5 | { 6 | 7 | public interface IWebhookEventHandler 8 | { 9 | 10 | } 11 | 12 | public interface IWebhookEventHandler : IWebhookEventHandler 13 | { 14 | Task Handle(T payload); 15 | } 16 | } -------------------------------------------------------------------------------- /Miro/Models/Github/Entities/CheckSuite.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Newtonsoft.Json; 3 | 4 | namespace Miro.Models.Github.Entities 5 | { 6 | public class CheckSuite 7 | { 8 | public string Status { get; set; } 9 | public string Conclusion { get; set; } 10 | [JsonProperty(PropertyName = "pull_requests")] 11 | public List PullRequests { get; set; } 12 | } 13 | } -------------------------------------------------------------------------------- /Miro.Tests/DummyEvents/Status.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 5, 3 | "sha": "a10867b14bb761a232cd80139fbd4c0d33264240", 4 | "branches": [{ 5 | "name": "some-branch" 6 | }], 7 | "repository": { 8 | "id": 526, 9 | "node_id": "MDEwOlJlcG9zaXRvcnkxMzU0OTMyMzM=", 10 | "name": "hello-world", 11 | "full_name": "github/hello-world", 12 | "owner": { 13 | "login": "github", 14 | "id": 340 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Miro.Tests/obj/Debug/netcoreapp2.1/project.razor.json: -------------------------------------------------------------------------------- 1 | { 2 | "ProjectFilePath": "/Users/itay/Soluto/Miro/Miro.Tests/Miro.Tests.csproj", 3 | "TargetFramework": "netcoreapp2.1", 4 | "TagHelpers": [], 5 | "Configuration": { 6 | "ConfigurationName": "UnsupportedRazor", 7 | "LanguageVersion": "1.0", 8 | "Extensions": [ 9 | { 10 | "ExtensionName": "UnsupportedRazorExtension" 11 | } 12 | ] 13 | } 14 | } -------------------------------------------------------------------------------- /Miro/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM microsoft/dotnet:sdk AS build-env 2 | WORKDIR /app 3 | 4 | # Copy csproj and restore as distinct layers 5 | COPY *.csproj ./ 6 | 7 | RUN dotnet restore 8 | 9 | # Copy everything else and build 10 | COPY . ./ 11 | RUN dotnet publish -c Release -o out 12 | 13 | # Build runtime image 14 | FROM microsoft/dotnet:aspnetcore-runtime 15 | WORKDIR /app 16 | 17 | COPY --from=build-env /app/out . 18 | 19 | ENTRYPOINT ["dotnet", "Miro.dll"] -------------------------------------------------------------------------------- /Miro/Models/Github/IncomingEvents/CheckSuiteEvent.cs: -------------------------------------------------------------------------------- 1 | using Miro.Models.Github.Entities; 2 | using Newtonsoft.Json; 3 | 4 | namespace Miro.Models.Github.IncomingEvents 5 | { 6 | public class CheckSuiteEvent 7 | { 8 | public string Action { get; set; } 9 | [JsonProperty(PropertyName = "check_suite")] 10 | public CheckSuite CheckSuite { get; set; } 11 | 12 | public Repository Repository { get; set; } 13 | } 14 | } -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting Security Issues 2 | 3 | If you discover a security issue in Miro, please report it by sending an email to security@soluto.com. 4 | 5 | We have a [S/MIME](security/smime.p7m) certificate for this address, use it to send encrypted mail messages. 6 | 7 | This will allow us to assess the risk, and make a fix available before we add a bug report to the GitHub repository. 8 | 9 | Thanks for helping make Miro safe for everyone. 10 | -------------------------------------------------------------------------------- /Miro/Models/Checks/CheckList.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using MongoDB.Bson; 4 | 5 | namespace Miro.Models.Checks 6 | { 7 | public class CheckList 8 | { 9 | public ObjectId Id { get; set; } 10 | public string Owner { get; set; } 11 | public string Repo { get; set; } 12 | public DateTime UpdatedAt { get; set; } 13 | public List CheckNames { get; set; } = new List(); 14 | } 15 | } -------------------------------------------------------------------------------- /Miro/Models/Github/IncomingEvents/IssueComentEvent.cs: -------------------------------------------------------------------------------- 1 | using Miro.Models.Github.Entities; 2 | using Newtonsoft.Json; 3 | 4 | namespace Miro.Models.Github.IncomingEvents 5 | { 6 | public class IssueCommentEvent 7 | { 8 | public string Action { get; set; } 9 | public Comment Comment { get; set; } 10 | public Issue Issue { get; set; } 11 | public User Sender { get; set; } 12 | public Repository Repository { get; set; } 13 | } 14 | } -------------------------------------------------------------------------------- /Miro/.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.*~ 3 | project.lock.json 4 | .DS_Store 5 | *.pyc 6 | nupkg/ 7 | 8 | # Visual Studio Code 9 | # .vscode 10 | 11 | # User-specific files 12 | *.suo 13 | *.user 14 | *.userosscache 15 | *.sln.docstates 16 | 17 | # Build results 18 | [Dd]ebug/ 19 | [Dd]ebugPublic/ 20 | [Rr]elease/ 21 | [Rr]eleases/ 22 | x64/ 23 | x86/ 24 | build/ 25 | bld/ 26 | [Bb]in/ 27 | [Oo]bj/ 28 | [Oo]ut/ 29 | msbuild.log 30 | msbuild.err 31 | msbuild.wrn 32 | 33 | # Visual Studio 2015 34 | .vs/ -------------------------------------------------------------------------------- /Miro/Models/Github/IncomingEvents/PullRequestEvent.cs: -------------------------------------------------------------------------------- 1 | using Miro.Models.Github.Entities; 2 | using Newtonsoft.Json; 3 | 4 | namespace Miro.Models.Github.IncomingEvents 5 | { 6 | public class PullRequestEvent 7 | { 8 | public string Action { get; set; } 9 | public int Number { get; set; } 10 | 11 | [JsonProperty(PropertyName = "pull_request")] 12 | public PullRequest PullRequest { get; set; } 13 | public Repository Repository { get; set; } 14 | } 15 | } -------------------------------------------------------------------------------- /Miro.Tests/MockGithubApi/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8 2 | 3 | # Create app directory 4 | WORKDIR /src 5 | 6 | # Install app dependencies 7 | # A wildcard is used to ensure both package.json AND package-lock.json are copied 8 | # where available (npm@5+) 9 | COPY package*.json ./ 10 | 11 | RUN npm install 12 | # If you are building your code for production 13 | # RUN npm install --only=production 14 | 15 | # Bundle app source 16 | COPY . . 17 | 18 | EXPOSE 8080 19 | EXPOSE 1234 20 | CMD [ "node", "index.js" ] -------------------------------------------------------------------------------- /Miro/Models/Github/IncomingEvents/PullRequestReviewEvent.cs: -------------------------------------------------------------------------------- 1 | using Miro.Models.Github.Entities; 2 | using Newtonsoft.Json; 3 | 4 | namespace Miro.Models.Github.IncomingEvents 5 | { 6 | public class PullRequestReviewEvent 7 | { 8 | public string Action { get; set; } 9 | public Review Review { get; set; } 10 | 11 | [JsonProperty(PropertyName = "pull_request")] 12 | public PullRequest PullRequest { get; set; } 13 | public Repository Repository { get; set; } 14 | } 15 | } -------------------------------------------------------------------------------- /Miro/Models/Github/RequestPayloads/UpdateBranchPayload.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | 3 | namespace Miro.Models.Github.RequestPayloads 4 | { 5 | public class UpdateBranchPayload 6 | { 7 | [JsonProperty(PropertyName = "head")] 8 | public string Head { get; set; } 9 | 10 | [JsonProperty(PropertyName = "base")] 11 | public string Base { get; set; } 12 | 13 | [JsonProperty(PropertyName = "commit_message")] 14 | public string CommitMessage { get; set; } 15 | } 16 | } -------------------------------------------------------------------------------- /Miro/Models/Github/Responses/WebhookResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Miro.Models.Github.Entities; 3 | using Newtonsoft.Json; 4 | 5 | namespace Miro.Models.Github.Responses 6 | { 7 | public class WebhookResponse 8 | { 9 | public WebhookResponse(bool handled, string message) 10 | { 11 | this.Handled = handled; 12 | this.Message = message; 13 | } 14 | 15 | public bool Handled { get; set; } 16 | public string Message { get; set; } 17 | } 18 | } -------------------------------------------------------------------------------- /Miro/Models/Github/RequestPayloads/MergePrPayload.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | 3 | namespace Miro.Models.Github.RequestPayloads 4 | { 5 | public class MergePrPayload 6 | { 7 | [JsonProperty(PropertyName = "commit_title")] 8 | public string CommitTitle { get; set; } 9 | 10 | [JsonProperty(PropertyName = "commit_message")] 11 | public string CommitMessage { get; set; } 12 | 13 | [JsonProperty(PropertyName = "merge_method")] 14 | public string MergeMethod { get; set; } 15 | 16 | public string Sha { get; set; } 17 | } 18 | } -------------------------------------------------------------------------------- /Miro/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Serilog": { 3 | "Using": [ "Serilog.Sinks.Console" ], 4 | "MinimumLevel": { 5 | "Default": "Information", 6 | "Override": { 7 | "Microsoft": "Warning", 8 | "System": "Warning" 9 | } 10 | }, 11 | "WriteTo": [ 12 | { 13 | "Name": "Console", 14 | "Args": { 15 | "formatter": "Serilog.Formatting.Json.JsonFormatter, Serilog" 16 | } 17 | } 18 | ], 19 | "Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Miro/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Serilog": { 3 | "Using": [ "Serilog.Sinks.Console" ], 4 | "MinimumLevel": { 5 | "Default": "Information", 6 | "Override": { 7 | "Microsoft": "Warning", 8 | "System": "Warning" 9 | } 10 | }, 11 | "WriteTo": [ 12 | { 13 | "Name": "Console", 14 | "Args": { 15 | "formatter": "Serilog.Formatting.Json.JsonFormatter, Serilog" 16 | } 17 | } 18 | ], 19 | "Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ] 20 | }, 21 | "AllowedHosts": "*" 22 | } 23 | -------------------------------------------------------------------------------- /Miro/Models/Github/Entities/PullRequest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Newtonsoft.Json; 3 | 4 | namespace Miro.Models.Github.Entities 5 | { 6 | public class PullRequest 7 | { 8 | public int Number { get; set; } 9 | public string State { get; set; } 10 | public string Title { get; set; } 11 | public User User { get; set; } 12 | public Head Head { get; set; } 13 | public Base Base { get; set; } 14 | public bool Merged { get; set; } 15 | 16 | [JsonProperty(PropertyName = "created_at")] 17 | public DateTime CreatedAt { get; set; } 18 | } 19 | } -------------------------------------------------------------------------------- /Miro/Models/Github/IncomingEvents/StatusEvent.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Miro.Models.Github.Entities; 3 | using Newtonsoft.Json; 4 | 5 | namespace Miro.Models.Github.IncomingEvents 6 | { 7 | public class StatusEvent 8 | { 9 | public string Sha { get; set; } 10 | public Repository Repository { get; set; } 11 | public string State { get; set; } 12 | public string Context { get; set; } 13 | 14 | [JsonProperty(PropertyName = "target_url")] 15 | public string TargetUrl { get; set; } 16 | public List Branches { get; set; } 17 | } 18 | } -------------------------------------------------------------------------------- /Miro/Models/Github/RequestPayloads/UpdateStatusCheckPayload.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | 3 | namespace Miro.Models.Github.RequestPayloads 4 | { 5 | public class UpdateStatusCheckPayload 6 | { 7 | [JsonProperty(PropertyName = "state")] 8 | public string State { get; set; } 9 | 10 | [JsonProperty(PropertyName = "target_url")] 11 | public string TargetUrl { get; set; } 12 | 13 | [JsonProperty(PropertyName = "description")] 14 | public string Description { get; set; } 15 | 16 | [JsonProperty(PropertyName = "context")] 17 | public string Context { get; set; } 18 | } 19 | } -------------------------------------------------------------------------------- /Miro/Docs/event_handlers.md: -------------------------------------------------------------------------------- 1 | issueCommet --> 2 | when receiving merge command, check if all checks for the pr already passed (pr.status == READY). If true, merge the PR. if false, change it's status to QUEUED 3 | 4 | pullRequest --> 5 | when receiving an event saying a new PR was created, insert a new document to the MergeRequests collection with status PENDING 6 | 7 | checkSuite --> 8 | when receiving an event saying a check suite is completed, check if it succeded. 9 | If true AND pr status is QUEUED --> merge the PR. 10 | If true AND pr status is PENDING --> update PR status to READY 11 | If false, change the PR status to FAILED. 12 | -------------------------------------------------------------------------------- /Miro.Tests/Helpers/MockMergeGithubCallHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Newtonsoft.Json; 4 | using static Miro.Tests.Helpers.GithubApiMock; 5 | using static Miro.Tests.Helpers.GithubUrlHelpers; 6 | 7 | namespace Miro.Tests.Helpers 8 | { 9 | public static class MockMergeGithubCallHelper 10 | { 11 | public static Task MockMergeCall(string owner, string repo, int prId, bool success = true) 12 | { 13 | var mergeResponse = new 14 | { 15 | merged = success 16 | }; 17 | return MockGithubCall("put", $"{PrUrlFor(owner, repo, prId)}/merge", JsonConvert.SerializeObject(mergeResponse), true); 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /Miro/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using Microsoft.AspNetCore; 7 | using Microsoft.AspNetCore.Hosting; 8 | using Microsoft.Extensions.Configuration; 9 | using Microsoft.Extensions.Logging; 10 | 11 | namespace Miro 12 | { 13 | public class Program 14 | { 15 | public static void Main(string[] args) 16 | { 17 | CreateWebHostBuilder(args).Build().Run(); 18 | } 19 | 20 | public static IWebHostBuilder CreateWebHostBuilder(string[] args) => 21 | WebHost.CreateDefaultBuilder(args) 22 | .UseStartup(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Miro.Tests/Miro.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp2.1 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /Miro.Tests/Helpers/MockRequiredChecksGithubCallHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Newtonsoft.Json; 4 | using static Miro.Tests.Helpers.GithubApiMock; 5 | using static Miro.Tests.Helpers.GithubUrlHelpers; 6 | 7 | namespace Miro.Tests.Helpers 8 | { 9 | public static class MockRequiredChecksGithubCallHelper 10 | { 11 | public static async Task MockRequiredChecks(string owner, string repo, string[] requiredChecks = null, string branch = "master") 12 | { 13 | var mockedRequiredTests = new { 14 | contexts = requiredChecks ?? new string[]{Consts.TEST_CHECK_A, Consts.TEST_CHECK_B} 15 | }; 16 | return await MockGithubCall("get", RequiredChecksUrlFor(owner, repo, branch), JsonConvert.SerializeObject(mockedRequiredTests), true); 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /Miro/Controllers/IsAliveController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Net.Http; 6 | using System.Net.Http.Headers; 7 | using System.Text; 8 | using System.Threading.Tasks; 9 | using Microsoft.AspNetCore.Mvc; 10 | using Microsoft.AspNetCore.WebHooks; 11 | using Microsoft.Extensions.Logging; 12 | using Miro.Models.Github.IncomingEvents; 13 | using Miro.Models.Github.Responses; 14 | using Miro.Services.Github; 15 | using Miro.Services.Github.EventHandlers; 16 | using Miro.Services.Merge; 17 | using Newtonsoft.Json.Linq; 18 | 19 | namespace Miro.Controllers 20 | { 21 | 22 | [Route("api/isAlive")] 23 | public class IsAliveController : ControllerBase 24 | { 25 | 26 | [HttpGet] 27 | public bool IsAlive() 28 | { 29 | return true; 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Miro.Tests/DummyEvents/PullRequest.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "opened", 3 | "number": 1, 4 | "pull_request": { 5 | "id": 191568743, 6 | "number": 1, 7 | "locked": false, 8 | "title": "Update the README with new information", 9 | "user": { 10 | "login": "TEST-PR-AUTHOR", 11 | "id": 21031067 12 | }, 13 | "head": { 14 | "ref": "some-branch", 15 | "sha": "b49ce9dd575f4faebac17220e82368c1" 16 | }, 17 | "base": { 18 | "ref": "master" 19 | }, 20 | "body": "This is a pretty simple change that we need to pull into master.", 21 | "mergeable": true, 22 | "rebaseable": true, 23 | "mergeable_state": "clean" 24 | }, 25 | "repository": { 26 | "id": 135493233, 27 | "name": "TEST-REPO", 28 | "owner": { 29 | "login": "TEST-OWNER", 30 | "id": 21031067 31 | } 32 | }, 33 | "sender": { 34 | "login": "TEST-PR-AUTHOR" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Miro/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:50911", 8 | "sslPort": 44351 9 | } 10 | }, 11 | "profiles": { 12 | "IIS Express": { 13 | "commandName": "IISExpress", 14 | "launchBrowser": true, 15 | "launchUrl": "api/values", 16 | "environmentVariables": { 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | } 19 | }, 20 | "Miro": { 21 | "commandName": "Project", 22 | "launchBrowser": true, 23 | "launchUrl": "api/values", 24 | "applicationUrl": "https://localhost:5001;http://localhost:5000", 25 | "environmentVariables": { 26 | "ASPNETCORE_ENVIRONMENT": "Development" 27 | } 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /Miro/Services/Github/PullRequestMismatchException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Runtime.Serialization; 3 | 4 | namespace Miro.Services.Github 5 | { 6 | [Serializable] 7 | internal class PullRequestMismatchException : Exception 8 | { 9 | private object httpResponse; 10 | 11 | public PullRequestMismatchException() 12 | { 13 | } 14 | 15 | public PullRequestMismatchException(object httpResponse) 16 | { 17 | this.httpResponse = httpResponse; 18 | } 19 | 20 | public PullRequestMismatchException(string message) : base(message) 21 | { 22 | } 23 | 24 | public PullRequestMismatchException(string message, Exception innerException) : base(message, innerException) 25 | { 26 | } 27 | 28 | protected PullRequestMismatchException(SerializationInfo info, StreamingContext context) : base(info, context) 29 | { 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /Miro/Miro.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp2.1 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /Miro/Services/Logger/LoggerExt.cs: -------------------------------------------------------------------------------- 1 | using Miro.Models.Merge; 2 | using Serilog; 3 | 4 | namespace Miro.Services.Logger 5 | { 6 | public static class LoggerExt 7 | { 8 | public static ILogger WithExtraData(this ILogger logger, object extraData) 9 | { 10 | return logger.ForContext("ExtraData", extraData, true); 11 | } 12 | 13 | public static ILogger WithMergeRequestData(this ILogger logger, MergeRequest mergeRequest) 14 | { 15 | return logger.ForContext("MergeRequestData", new 16 | { 17 | owner = mergeRequest.Owner, 18 | repo = mergeRequest.Repo, 19 | branch = mergeRequest.Branch, 20 | prId = mergeRequest.PrId, 21 | sha = mergeRequest.Sha, 22 | title = mergeRequest.Title, 23 | receivedMergeCommand = mergeRequest.ReceivedMergeCommand, 24 | }); 25 | } 26 | 27 | } 28 | } -------------------------------------------------------------------------------- /Miro.Tests/DummyEvents/ReviewPullRequest.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "submitted", 3 | "review": { 4 | "state": "approved", 5 | "id": 191568743, 6 | "title": "Update the README with new information", 7 | "user": { 8 | "login": "TEST-PR-AUTHOR", 9 | "id": 21031067 10 | } 11 | }, 12 | "pull_request": { 13 | "id": 191568743, 14 | "number": 1, 15 | "locked": false, 16 | "title": "Update the README with new information", 17 | "user": { 18 | "login": "TEST-PR-AUTHOR", 19 | "id": 21031067 20 | }, 21 | "head": { 22 | "ref": "some-branch" 23 | }, 24 | "body": "This is a pretty simple change that we need to pull into master.", 25 | "mergeable": true, 26 | "rebaseable": true, 27 | "mergeable_state": "clean" 28 | }, 29 | "repository": { 30 | "id": 135493233, 31 | "name": "TEST-REPO", 32 | "owner": { 33 | "login": "TEST-OWNER", 34 | "id": 21031067 35 | } 36 | }, 37 | "sender": { 38 | "login": "TEST-PR-AUTHOR" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Miro.Tests/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | miro-app: 5 | build: ../Miro 6 | depends_on: 7 | - mock_github_api 8 | - mongo 9 | environment: 10 | - ASPNETCORE_ENVIRONMENT=Development 11 | - GITHUB_API_URL=http://mock_github_api:1234/ 12 | - MONGO_CONNECTION_STRING=mongodb://mongo:27017 13 | - WEBHOOKS__GITHUB__SECRETKEY__DEFAULT=614d038b841a4846e27a92cc4b25ce5e54e1ae4a 14 | ports: 15 | - "5000:80" 16 | 17 | mock_github_api: 18 | build: ./MockGithubApi 19 | ports: 20 | - "3000:3000" 21 | - "1234:1234" 22 | 23 | mongo: 24 | image: mongo:4.1 25 | ports: 26 | - "27017:27017" 27 | 28 | miro-tests: 29 | build: . 30 | depends_on: 31 | - mock_github_api 32 | - mongo 33 | - miro-app 34 | environment: 35 | - MONGO_CONNECTION_STRING=mongodb://mongo:27017 36 | - SERVER_URL=http://miro-app:80 37 | - GITHUB_API_URL=http://mock_github_api:3000 38 | volumes: 39 | - ./logs:/app/TestResults 40 | 41 | -------------------------------------------------------------------------------- /Miro.Tests/obj/Debug/netcoreapp2.1/Miro.Tests.AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | //------------------------------------------------------------------------------ 2 | // 3 | // This code was generated by a tool. 4 | // Runtime Version:4.0.30319.42000 5 | // 6 | // Changes to this file may cause incorrect behavior and will be lost if 7 | // the code is regenerated. 8 | // 9 | //------------------------------------------------------------------------------ 10 | 11 | using System; 12 | using System.Reflection; 13 | 14 | [assembly: System.Reflection.AssemblyCompanyAttribute("Miro.Tests")] 15 | [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] 16 | [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] 17 | [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0")] 18 | [assembly: System.Reflection.AssemblyProductAttribute("Miro.Tests")] 19 | [assembly: System.Reflection.AssemblyTitleAttribute("Miro.Tests")] 20 | [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] 21 | 22 | // Generated by the MSBuild WriteCodeFragment class. 23 | 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Soluto by Asurion 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Miro/Services/Github/ReviewsRetriever.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Net.Http; 4 | using System.Threading.Tasks; 5 | using Miro.Models.Github.Entities; 6 | using Miro.Models.Github.Responses; 7 | 8 | namespace Miro.Services.Github 9 | { 10 | public class ReviewsRetriever 11 | { 12 | private readonly GithubHttpClient githubHttpClient; 13 | 14 | public ReviewsRetriever(GithubHttpClient githubHttpClient) 15 | { 16 | this.githubHttpClient = githubHttpClient; 17 | } 18 | public async Task GetRequestedReviewers(string owner, string repo, int prId) 19 | { 20 | var reviewRequests = await githubHttpClient.Get($"/repos/{owner}/{repo}/pulls/{prId}/requested_reviewers"); 21 | 22 | return reviewRequests; 23 | } 24 | 25 | public async Task> GetReviews(string owner, string repo, int prId) 26 | { 27 | var reviews = await githubHttpClient.Get>($"/repos/{owner}/{repo}/pulls/{prId}/reviews"); 28 | 29 | return reviews; 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to MIRO 2 | 3 | 4 | 5 | - [Contributing to MIRO](#contributing-to-miro) 6 | - [Set up environment](#set-up-environment) 7 | - [Testing](#testing) 8 | - [Testing Locally](#testing-locally) 9 | 10 | 11 | 12 | ## Set up environment 13 | 1. install .net core sdk 14 | 2. if working with vs code --> install c# extension 15 | 16 | ## Testing 17 | 18 | Run the test suite 19 | 1. `cd Miro.Tests && docker-compose up --build --abort-on-container-exit` 20 | 21 | If you want to run the tests separately without re-building the app 22 | 1. `cd Miro.Tests` 23 | 2. `docker-compose build` - Will Build the bot, GithubApi, MongoDb 24 | 3. `docker-compose run --rm --name miro-tests miro-tests` - Will Run the tests 25 | 26 | To run a single test, replace step (3) with this: 27 | 1. `docker-compose run --rm --name miro-tests --entrypoint 'dotnet test --filter "Miro.Tests.."' miro-tests` 28 | 29 | ## Testing Locally 30 | If you want to run the tests without docker 31 | 1. `cd Miro.Tests` 32 | 2. `docker-compose build` 33 | 3. `docker-compose run --rm --service-ports --name miro-app miro-app` 34 | 4. `dotnet restore && dotnet test` 35 | 36 | 37 | -------------------------------------------------------------------------------- /Miro.Tests/Helpers/MockReviewGithubCallHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Newtonsoft.Json; 4 | using static Miro.Tests.Helpers.GithubApiMock; 5 | using static Miro.Tests.Helpers.GithubUrlHelpers; 6 | 7 | namespace Miro.Tests.Helpers 8 | { 9 | public static class MockReviewGithubCallHelper 10 | { 11 | public static async Task MockReviewsResponses(string requestedReviews, string madeReviews, string owner, string repo, int prId) 12 | { 13 | await MockGithubCall("get", $"{PrUrlFor(owner, repo, prId)}/requested_reviewers", requestedReviews); 14 | await MockGithubCall("get", $"{PrUrlFor(owner, repo, prId)}/reviews", madeReviews); 15 | } 16 | 17 | public static Task MockAllReviewsPassedResponses(string owner, string repo, int prId) 18 | { 19 | var requestedReviewsMockedResponse = new 20 | { 21 | teams = Array.Empty(), 22 | users = Array.Empty() 23 | }; 24 | var body = JsonConvert.SerializeObject(requestedReviewsMockedResponse); 25 | 26 | return MockReviewsResponses(body, "[]", owner, repo, prId); 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /Miro.Tests/Helpers/MockRepoConfigGithubCallHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Text; 4 | using System.Threading.Tasks; 5 | using Newtonsoft.Json; 6 | using static Miro.Tests.Helpers.GithubApiMock; 7 | using static Miro.Tests.Helpers.GithubUrlHelpers; 8 | 9 | namespace Miro.Tests.Helpers 10 | { 11 | public static class MockRepoConfigGithubCallHelper 12 | { 13 | public static async Task MockRepoConfigGithubCall(string owner, string repo, string configPath) 14 | { 15 | var configString = await File.ReadAllTextAsync($"../../../DummyConfigYamls/{configPath}"); 16 | byte[] textAsBytes = Encoding.UTF8.GetBytes(configString); 17 | var content = Convert.ToBase64String(textAsBytes); 18 | var configFileResponse = new 19 | { 20 | content 21 | }; 22 | return await MockGithubCall("get", GetConfigFileFor(owner, repo), JsonConvert.SerializeObject(configFileResponse), true); 23 | } 24 | 25 | public static async Task MockFailingRepoConfigCall(string owner, string repo) 26 | { 27 | return await MockGithubCall("get", GetConfigFileFor(owner, repo), null, 404); 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /Miro.Tests/obj/Miro.Tests.csproj.nuget.g.targets: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | $(MSBuildAllProjects);$(MSBuildThisFileFullPath) 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Miro/Models/Merge/MergeRequest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Miro.Models.Checks; 5 | using MongoDB.Bson; 6 | 7 | namespace Miro.Models.Merge 8 | { 9 | public class MergeRequest 10 | { 11 | public ObjectId Id { get; set; } 12 | public string Owner { get; set; } 13 | public string Repo { get; set; } 14 | public int PrId { get; set; } 15 | public string Title { get; set; } 16 | public string Author { get; set; } 17 | public DateTime CreatedAt { get; set; } 18 | public List Checks { get; set; } = new List(); 19 | public bool ReceivedMergeCommand { get; set; } 20 | public DateTime? ReceivedMergeCommandTimestamp { get; set; } 21 | public string Branch { get; set; } 22 | public string State { get; set; } 23 | public string Sha { get; set; } 24 | public bool IsFork { get; set; } 25 | 26 | 27 | } 28 | 29 | public static class MergeRequestExt 30 | { 31 | public static bool NoFailingChecks(this MergeRequest mergeRequest) => mergeRequest.Checks.All(x => x.Status != "failure" && x.Status != "error"); 32 | public static bool AllPassingChecks(this MergeRequest mergeRequest) => mergeRequest.Checks.All(x => x.Status != "success"); 33 | } 34 | } -------------------------------------------------------------------------------- /Miro.Tests/MockGithubApi/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # parcel-bundler cache (https://parceljs.org/) 61 | .cache 62 | 63 | # next.js build output 64 | .next 65 | 66 | # nuxt.js build output 67 | .nuxt 68 | 69 | # vuepress build output 70 | .vuepress/dist 71 | 72 | # Serverless directories 73 | .serverless -------------------------------------------------------------------------------- /Miro/Models/MiroConfig/RepoConfig.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using MongoDB.Bson; 3 | using MongoDB.Bson.Serialization.Attributes; 4 | 5 | namespace Miro.Models.MiroConfig 6 | { 7 | [BsonIgnoreExtraElements] 8 | public class RepoConfig 9 | { 10 | public ObjectId Id { get; set; } 11 | public string Owner { get; set; } 12 | public string Repo { get; set; } 13 | public string MergePolicy { get; set; } = "whitelist"; 14 | public string UpdateBranchStrategy { get; set; } = "oldest"; 15 | public string DefaultBranch { get; set; } = "master"; 16 | public bool Quiet { get; set; } = false; 17 | public DateTime UpdatedAt { get; set; } 18 | } 19 | 20 | public static class RepoConfigExt 21 | { 22 | 23 | public static bool IsWhitelistStrict(this RepoConfig repoConfig) => repoConfig.MergePolicy == "whitelist-strict"; 24 | public static bool IsValidMergePolicy(this RepoConfig repoConfig) => repoConfig.MergePolicy == "whitelist" || repoConfig.MergePolicy == "whitelist-strict" || repoConfig.MergePolicy == "blacklist"; 25 | public static bool IsValidUpdateBranchStrategy(this RepoConfig repoConfig) => repoConfig.UpdateBranchStrategy == "oldest" || repoConfig.UpdateBranchStrategy == "all" || repoConfig.UpdateBranchStrategy == "none"; 26 | public static bool IsBlacklist(this RepoConfig repoConfig) => repoConfig.MergePolicy == "blacklist"; 27 | 28 | } 29 | 30 | } -------------------------------------------------------------------------------- /Miro.Tests/Helpers/GithubUrlHelpers.cs: -------------------------------------------------------------------------------- 1 | namespace Miro.Tests.Helpers 2 | { 3 | public static class GithubUrlHelpers 4 | { 5 | public static string IssueUrlFor(string owner, string repo, int prId) 6 | { 7 | return $"/repos/{owner}/{repo}/issues/{prId}"; 8 | } 9 | 10 | public static string PrUrlFor(string owner, string repo, int prId) 11 | { 12 | return $"/repos/{owner}/{repo}/pulls/{prId}"; 13 | } 14 | 15 | public static string MergesUrlFor(string owner, string repo) 16 | { 17 | return $"/repos/{owner}/{repo}/merges"; 18 | } 19 | 20 | public static string GetConfigFileFor(string owner, string repo) 21 | { 22 | return $"/repos/{owner}/{repo}/contents/.miro.yml"; 23 | } 24 | 25 | public static string RequiredChecksUrlFor(string owner, string repo, string branch = "some-branch") 26 | { 27 | return $"/repos/{owner}/{repo}/branches/{branch}/protection/required_status_checks"; 28 | } 29 | public static string StatusCheckUrlFor(string owner, string repo, string branchOrSha) 30 | { 31 | return $"/repos/{owner}/{repo}/statuses/{branchOrSha}"; 32 | } 33 | 34 | public static string DeleteBranchUrlFor(string owner, string repo, string branch) 35 | { 36 | return $"/repos/{owner}/{repo}/git/refs/heads/{branch}"; 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /Miro/Services/Checks/ChecksRepository.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using Miro.Models.Checks; 5 | using Miro.Models.Merge; 6 | using MongoDB.Driver; 7 | 8 | namespace Miro.Services.Checks 9 | { 10 | public class ChecksRepository 11 | { 12 | private readonly IMongoCollection collection; 13 | 14 | public ChecksRepository(IMongoCollection collection) 15 | { 16 | this.collection = collection; 17 | } 18 | 19 | public async Task Get(string owner, string repo) 20 | { 21 | return await collection.Find(r => r.Owner == owner && r.Repo == repo).FirstOrDefaultAsync(); 22 | } 23 | 24 | public async Task Update(string owner, string repo, List checks) 25 | { 26 | var options = new FindOneAndUpdateOptions 27 | { 28 | IsUpsert = true 29 | }; 30 | 31 | var update = Builders.Update 32 | .Set(r => r.CheckNames, checks) 33 | .Set(d => d.UpdatedAt, DateTime.UtcNow); 34 | 35 | return await collection.FindOneAndUpdateAsync(r => r.Owner == owner && r.Repo == repo, update, options); 36 | } 37 | 38 | public async Task Create(CheckList checksCollection) 39 | { 40 | await collection.InsertOneAsync(checksCollection); 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /Miro.Tests/Helpers/CheckListsCollection.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using MongoDB.Bson; 6 | using MongoDB.Driver; 7 | 8 | namespace Miro.Tests.Helpers 9 | { 10 | public class CheckListsCollection 11 | { 12 | private static string MongoUrl = Environment.GetEnvironmentVariable("MONGO_CONNECTION_STRING"); 13 | 14 | private string[] defaultChecks = new string[]{Consts.TEST_CHECK_A, Consts.TEST_CHECK_B}; 15 | public CheckListsCollection() 16 | { 17 | var client = new MongoClient(MongoUrl ?? "mongodb://localhost:27017"); 18 | var database = client.GetDatabase("miro-db"); 19 | this.Collection = database.GetCollection("check-lists"); 20 | } 21 | 22 | public IMongoCollection Collection { get; } 23 | 24 | public async Task Insert(string owner, string repo, IEnumerable CheckNames = null) 25 | { 26 | var existingMergeRequest = new BsonDocument(); 27 | existingMergeRequest["Owner"] = owner; 28 | existingMergeRequest["Repo"] = repo; 29 | 30 | if (CheckNames != null) { 31 | existingMergeRequest["CheckNames"] = new BsonArray(CheckNames); 32 | } 33 | 34 | await Collection.InsertOneAsync(existingMergeRequest); 35 | } 36 | 37 | public Task InsertWithDefaultChecks(string owner, string repo) => Insert(owner, repo, defaultChecks); 38 | } 39 | } -------------------------------------------------------------------------------- /Miro/Services/Utils/DictionaryExt.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Reflection; 5 | using Miro.Services.Logger; 6 | using Miro.Services.MiroConfig; 7 | using Serilog; 8 | 9 | namespace Miro.Services.Utils 10 | { 11 | public static class DictionaryExt 12 | { 13 | public static T ToObject(this Dictionary dict) 14 | { 15 | return (T)GetObject(dict, typeof(T)); 16 | } 17 | 18 | private static Object GetObject(this Dictionary dict, Type type) 19 | { 20 | var obj = Activator.CreateInstance(type); 21 | var allProperties = type.GetProperties(); 22 | 23 | foreach (var kv in dict) 24 | { 25 | foreach (var prop in allProperties) 26 | { 27 | if (prop.Name.Equals(kv.Key, StringComparison.OrdinalIgnoreCase)) 28 | { 29 | object value = kv.Value; 30 | if (prop.PropertyType == typeof(Boolean)) 31 | { 32 | prop.SetValue(obj, (string) value == "true" ? true : false, null); 33 | } 34 | else 35 | { 36 | prop.SetValue(obj, value, null); 37 | } 38 | } 39 | } 40 | } 41 | return obj; 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /Miro.Tests/obj/Debug/netcoreapp2.1/Miro.Tests.csproj.FileListAbsolute.txt: -------------------------------------------------------------------------------- 1 | /Users/itay/Soluto/Miro/Miro.Tests/bin/Debug/netcoreapp2.1/xunit.runner.visualstudio.dotnetcore.testadapter.dll 2 | /Users/itay/Soluto/Miro/Miro.Tests/bin/Debug/netcoreapp2.1/xunit.runner.reporters.netcoreapp10.dll 3 | /Users/itay/Soluto/Miro/Miro.Tests/bin/Debug/netcoreapp2.1/xunit.runner.utility.netcoreapp10.dll 4 | /Users/itay/Soluto/Miro/Miro.Tests/bin/Debug/netcoreapp2.1/Miro.Tests.deps.json 5 | /Users/itay/Soluto/Miro/Miro.Tests/bin/Debug/netcoreapp2.1/Miro.Tests.runtimeconfig.json 6 | /Users/itay/Soluto/Miro/Miro.Tests/bin/Debug/netcoreapp2.1/Miro.Tests.runtimeconfig.dev.json 7 | /Users/itay/Soluto/Miro/Miro.Tests/bin/Debug/netcoreapp2.1/Miro.Tests.dll 8 | /Users/itay/Soluto/Miro/Miro.Tests/bin/Debug/netcoreapp2.1/Miro.Tests.pdb 9 | /Users/itay/Soluto/Miro/Miro.Tests/bin/Debug/netcoreapp2.1/Miro.dll 10 | /Users/itay/Soluto/Miro/Miro.Tests/bin/Debug/netcoreapp2.1/Miro.pdb 11 | /Users/itay/Soluto/Miro/Miro.Tests/obj/Debug/netcoreapp2.1/Miro.Tests.csprojAssemblyReference.cache 12 | /Users/itay/Soluto/Miro/Miro.Tests/obj/Debug/netcoreapp2.1/Miro.Tests.csproj.CoreCompileInputs.cache 13 | /Users/itay/Soluto/Miro/Miro.Tests/obj/Debug/netcoreapp2.1/Miro.Tests.AssemblyInfoInputs.cache 14 | /Users/itay/Soluto/Miro/Miro.Tests/obj/Debug/netcoreapp2.1/Miro.Tests.AssemblyInfo.cs 15 | /Users/itay/Soluto/Miro/Miro.Tests/obj/Debug/netcoreapp2.1/Miro.Tests.csproj.CopyComplete 16 | /Users/itay/Soluto/Miro/Miro.Tests/obj/Debug/netcoreapp2.1/Miro.Tests.dll 17 | /Users/itay/Soluto/Miro/Miro.Tests/obj/Debug/netcoreapp2.1/Miro.Tests.pdb 18 | -------------------------------------------------------------------------------- /Miro/Services/Github/FileRetriever.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Miro.Models.Github.Entities; 4 | using Miro.Services.Logger; 5 | using Serilog; 6 | 7 | namespace Miro.Services.Github 8 | { 9 | public class FileRetriever 10 | { 11 | private readonly GithubHttpClient githubHttpClient; 12 | private readonly ILogger logger = Log.ForContext(); 13 | 14 | 15 | public FileRetriever(GithubHttpClient githubHttpClient) 16 | { 17 | this.githubHttpClient = githubHttpClient; 18 | } 19 | 20 | public async Task GetFile(string owner, string repo, string fileName) 21 | { 22 | logger.WithExtraData(new {owner, repo, fileName}).Information("Fetching file from repo"); 23 | try 24 | { 25 | var payload = await githubHttpClient.Get($"/repos/{owner}/{repo}/contents/{fileName}"); 26 | if (payload == null) 27 | { 28 | logger.WithExtraData(new {owner, repo, fileName}).Information("File not found"); 29 | return null; 30 | } 31 | logger.WithExtraData(new {owner, repo, fileName, name = payload.Name, sha = payload.Sha, content = payload.Content}).Information("File found from repo"); 32 | return payload; 33 | } 34 | catch (Exception e) 35 | { 36 | logger.WithExtraData(new {owner, repo, fileName}).Information("File not found"); 37 | return null; 38 | } 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /Miro.Tests/Helpers/RepoConfigurationCollection.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System; 4 | using System.Threading.Tasks; 5 | using MongoDB.Bson; 6 | using MongoDB.Driver; 7 | 8 | namespace Miro.Tests.Helpers 9 | { 10 | public class RepoConfigurationCollection 11 | { 12 | private static string MongoUrl = Environment.GetEnvironmentVariable("MONGO_CONNECTION_STRING"); 13 | 14 | public RepoConfigurationCollection() 15 | { 16 | var client = new MongoClient(MongoUrl ?? "mongodb://localhost:27017"); 17 | var database = client.GetDatabase("miro-db"); 18 | this.Collection = database.GetCollection("repo-config"); 19 | } 20 | 21 | public IMongoCollection Collection { get; } 22 | 23 | public async Task Insert(string owner, string repo, string updateBranchStrategy = "oldest", string mergePolicy = "blacklist", string defaultBranch = "master", bool quiet = false) 24 | { 25 | var repoConfig = new BsonDocument(); 26 | repoConfig["Owner"] = owner; 27 | repoConfig["Repo"] = repo; 28 | repoConfig["UpdateBranchStrategy"] = updateBranchStrategy; 29 | repoConfig["MergePolicy"] = mergePolicy; 30 | repoConfig["UpdatedAt"] = DateTime.UtcNow; 31 | repoConfig["DefaultBranch"] = defaultBranch; 32 | repoConfig["Quiet"] = quiet; 33 | await Collection.InsertOneAsync(repoConfig); 34 | } 35 | 36 | public async Task Get(string owner, string repo) 37 | { 38 | return await Collection.Find(r => r["Owner"] == owner).FirstOrDefaultAsync(); 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /Miro/Services/MiroConfig/RepoConfigRepository.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using Miro.Models.MiroConfig; 5 | using MongoDB.Driver; 6 | 7 | namespace MiroConfig 8 | { 9 | 10 | public class RepoConfigRepository 11 | { 12 | private readonly IMongoCollection collection; 13 | 14 | public RepoConfigRepository(IMongoCollection collection) 15 | { 16 | this.collection = collection; 17 | } 18 | 19 | public Task Get(string owner, string repo) 20 | { 21 | return collection.Find(r => r.Owner == owner && r.Repo == repo).FirstOrDefaultAsync(); 22 | } 23 | public Task> Get() => collection.Find(_ => true).ToListAsync(); 24 | 25 | public Task Create(RepoConfig config) 26 | { 27 | return collection.InsertOneAsync(config); 28 | } 29 | 30 | public Task Update(RepoConfig config) 31 | { 32 | config.UpdatedAt = DateTime.UtcNow; 33 | var options = new FindOneAndUpdateOptions 34 | { 35 | IsUpsert = true 36 | }; 37 | var update = Builders.Update 38 | .Set(r => r.UpdatedAt, DateTime.UtcNow) 39 | .Set(r => r.MergePolicy, config.MergePolicy) 40 | .Set(r => r.UpdateBranchStrategy, config.UpdateBranchStrategy) 41 | .Set(r => r.DefaultBranch, config.DefaultBranch) 42 | .Set(r => r.Quiet, config.Quiet); 43 | 44 | 45 | return collection.FindOneAndUpdateAsync(r => r.Owner == config.Owner && r.Repo == config.Repo, update, options); 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /Miro/Services/Github/PrDeleter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net.Http; 3 | using System.Threading.Tasks; 4 | using Miro.Models.Github.RequestPayloads; 5 | using Miro.Models.Github.Responses; 6 | using Miro.Services.Logger; 7 | using Miro.Services.Merge; 8 | using Newtonsoft.Json; 9 | using Serilog; 10 | 11 | namespace Miro.Services.Github 12 | { 13 | public class PrDeleter 14 | { 15 | private readonly GithubHttpClient githubHttpClient; 16 | private readonly ILogger logger = Log.ForContext(); 17 | public PrDeleter(GithubHttpClient githubHttpClient) 18 | { 19 | this.githubHttpClient = githubHttpClient; 20 | } 21 | public async Task DeleteBranch(string owner, string repo, string branch) 22 | { 23 | if (branch == null) 24 | { 25 | throw new Exception($"Could not update branch for {owner}/{repo}, branch unknown"); 26 | } 27 | 28 | string uri = $"/repos/{owner}/{repo}/git/refs/heads/{branch}"; 29 | logger.WithExtraData(new {owner, repo, branch}).Information($"Making github delete branch request"); 30 | 31 | var response = await githubHttpClient.Delete(uri); 32 | 33 | try { 34 | response.EnsureSuccessStatusCode(); 35 | } catch (HttpRequestException e) 36 | { 37 | string reason = null; 38 | try 39 | { 40 | var contentAsString = await response.Content.ReadAsStringAsync(); 41 | reason = JsonConvert.DeserializeObject(contentAsString); 42 | } 43 | catch (System.Exception){} 44 | logger.WithExtraData(new {reason, owner, repo, branch}).Error(e, $"Could not delete branch"); 45 | } 46 | 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /Miro/Models/Github/Entities/FileContent.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Text; 4 | using Miro.Services.Logger; 5 | using Miro.Services.Utils; 6 | using Serilog; 7 | 8 | namespace Miro.Models.Github.Entities 9 | { 10 | 11 | public class FileContent 12 | { 13 | 14 | public string Encoding { get; set; } 15 | public string Content { get; set; } 16 | public string Sha { get; set; } 17 | public string Name { get; set; } 18 | 19 | } 20 | 21 | public static class FileContentExt 22 | { 23 | private static readonly ILogger logger = Log.ForContext(); 24 | 25 | public static T DecodeContent(this FileContent fileContent, string format = "yaml") 26 | { 27 | var content = Encoding.UTF8.GetString(Convert.FromBase64String(fileContent.Content)); 28 | switch (format) 29 | { 30 | case "yaml": 31 | try 32 | { 33 | return content.Split("\n") 34 | .Where(x => !x.Contains("#") && x.Contains(":")) 35 | .ToDictionary(x => 36 | { 37 | var keysValues = x.Split(":"); 38 | return keysValues[0].Trim(); 39 | 40 | }, x => 41 | { 42 | var keysValues = x.Split(":"); 43 | return keysValues[1].Trim(); 44 | }).ToObject(); 45 | } 46 | catch (Exception e) 47 | { 48 | logger.WithExtraData(new {fileContent, content}).Error(e, "Could not parse into yaml format, returning default object"); 49 | return default(T); 50 | } 51 | 52 | 53 | default: 54 | throw new Exception("I dont know how to decode this"); 55 | } 56 | 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /Miro/Services/Auth/ApiKeyMiddleware.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | using Microsoft.AspNetCore.Http; 3 | using Microsoft.Extensions.Configuration; 4 | using System.Threading.Tasks; 5 | 6 | namespace Miro.Services.Auth 7 | { 8 | public class ApiKeyMiddleware 9 | { 10 | private readonly RequestDelegate next; 11 | private readonly IConfiguration configuration; 12 | 13 | public ApiKeyMiddleware(RequestDelegate next, IConfiguration configuration) 14 | { 15 | this.next = next; 16 | this.configuration = configuration; 17 | } 18 | 19 | public async Task Invoke(HttpContext context) 20 | { 21 | // Skip isAlive 22 | if (context.Request.Path.Value.Contains("api/isAlive")) 23 | { 24 | await next.Invoke(context); 25 | return; 26 | } 27 | 28 | var apiKey = configuration.GetValue("API_KEY"); 29 | 30 | if (apiKey == null) 31 | { 32 | await next.Invoke(context); 33 | return; 34 | } 35 | 36 | var reqApiKey = context.Request.Headers["Authorization"]; 37 | if (apiKey == null) 38 | { 39 | context.Response.StatusCode = 400; 40 | await context.Response.WriteAsync("Api Key is missing"); 41 | return; 42 | } 43 | if (reqApiKey != apiKey) 44 | { 45 | context.Response.StatusCode = 401; 46 | await context.Response.WriteAsync("Api Key is invalid"); 47 | return; 48 | } 49 | 50 | await next.Invoke(context); 51 | } 52 | } 53 | 54 | public static class ApiKeyMiddlewareExt 55 | { 56 | public static IApplicationBuilder ApplyApiKeyValidation(this IApplicationBuilder app) 57 | { 58 | app.UseMiddleware(); 59 | return app; 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /Miro.Tests/Helpers/WebhookRequestSender.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Net.Http; 5 | using System.Security.Cryptography; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | using Newtonsoft.Json; 9 | 10 | namespace Miro.Tests.Helpers 11 | { 12 | public class WebhookRequestSender 13 | { 14 | private static string ServerUrl = Environment.GetEnvironmentVariable("SERVER_URL"); 15 | 16 | public static async Task SendWebhookRequest(string eventType, string payload) 17 | { 18 | const string secret = "614d038b841a4846e27a92cc4b25ce5e54e1ae4a"; // match the secret defined in docker-compose for testing 19 | var webhookRequest = new HttpRequestMessage(HttpMethod.Post, $"{(ServerUrl ?? "http://localhost:5000")}/api/webhooks/incoming/github"); 20 | var signature = await ComputeGithubSignature(secret, payload); 21 | webhookRequest.Headers.Add("X-Hub-Signature", $"sha1={signature}"); 22 | webhookRequest.Headers.Add("X-Github-Event", eventType); 23 | webhookRequest.Content = new StringContent(payload, Encoding.UTF8, "application/json"); 24 | 25 | var httpClient = new HttpClient(); 26 | var response = await httpClient.SendAsync(webhookRequest); 27 | response.EnsureSuccessStatusCode(); 28 | } 29 | 30 | public static async Task ComputeGithubSignature(string secret, string @event) 31 | { 32 | byte[] secretKey = Encoding.UTF8.GetBytes(secret); 33 | 34 | var stream = new MemoryStream(); 35 | using (var writer = new StreamWriter(stream)) 36 | { 37 | await writer.WriteAsync(@event); 38 | await writer.FlushAsync(); 39 | stream.Position = 0; 40 | 41 | var hasher = new HMACSHA1(secretKey); 42 | return hasher.ComputeHash(stream).Aggregate("", (s, e) => s + string.Format("{0:x2}", e), s => s); 43 | } 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /Miro/Services/MiroStats/MiroStatsProvider.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using Miro.Services.Checks; 5 | using Miro.Services.Logger; 6 | using Miro.Services.Merge; 7 | using MiroConfig; 8 | using Serilog; 9 | 10 | namespace Miro.Services.MiroStats 11 | { 12 | public class MiroStatsProvider 13 | { 14 | private readonly MergeRequestsRepository mergeRequestsRepository; 15 | private readonly ChecksRepository checksRepository; 16 | private readonly RepoConfigRepository repoConfigRepository; 17 | private readonly ILogger logger = Log.ForContext(); 18 | 19 | 20 | public MiroStatsProvider( 21 | MergeRequestsRepository mergeRequestsRepository, 22 | ChecksRepository checksRepository, 23 | RepoConfigRepository repoConfigRepository) 24 | { 25 | this.mergeRequestsRepository = mergeRequestsRepository; 26 | this.checksRepository = checksRepository; 27 | this.repoConfigRepository = repoConfigRepository; 28 | } 29 | 30 | public async Task Get() 31 | { 32 | var allConfigs = await repoConfigRepository.Get(); 33 | var allRepos = allConfigs.GroupBy(x => $"{x.Owner}/{x.Repo}"); 34 | 35 | var count = allRepos.Count(); 36 | var names = new List(); 37 | 38 | foreach (var group in allRepos) 39 | { 40 | names.Add(group.Key); 41 | } 42 | 43 | var response = new MiroStats{ 44 | RepoNames = names, 45 | NumOfRepos = count 46 | }; 47 | logger.WithExtraData(response).Information("Calculated Miro Stats"); 48 | return response; 49 | } 50 | 51 | } 52 | 53 | public class MiroStats 54 | { 55 | public int NumOfRepos {get; set;} 56 | public List RepoNames {get; set;} = new List(); 57 | } 58 | } -------------------------------------------------------------------------------- /Miro/Services/Checks/ChecksRetriever.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using Microsoft.Extensions.Configuration; 7 | using Miro.Models.Checks; 8 | using Miro.Models.Github.Responses; 9 | using Miro.Models.Merge; 10 | using Miro.Services.Github; 11 | using Miro.Services.Github.EventHandlers; 12 | using Miro.Services.Logger; 13 | using Miro.Services.Merge; 14 | using Miro.Services.MiroConfig; 15 | using Serilog; 16 | 17 | namespace Miro.Services.Checks 18 | { 19 | public class ChecksRetriever 20 | { 21 | private readonly GithubHttpClient githubHttpClient; 22 | private readonly RepoConfigManager repoConfigManager; 23 | private readonly ILogger logger = Log.ForContext(); 24 | 25 | public ChecksRetriever( 26 | GithubHttpClient githubHttpClient, 27 | RepoConfigManager repoConfigManager) 28 | { 29 | this.githubHttpClient = githubHttpClient; 30 | this.repoConfigManager = repoConfigManager; 31 | } 32 | 33 | public async Task> GetRequiredChecks(string owner, string repo) 34 | { 35 | var config = await repoConfigManager.GetConfig(owner, repo); 36 | var defaultBranch = config.DefaultBranch; 37 | 38 | var uri = $"/repos/{owner}/{repo}/branches/{defaultBranch}/protection/required_status_checks"; 39 | logger.WithExtraData(new {owner, repo, defaultBranch, uri}).Information($"Retrieving required checks"); 40 | 41 | var requiredChecks = await githubHttpClient.Get(uri); 42 | if (requiredChecks == null || !requiredChecks.Contexts.Any()) 43 | { 44 | logger.WithExtraData(new {owner, repo}).Information($"No required checks found"); 45 | return null; 46 | } 47 | logger.WithExtraData(new {owner, repo, checks = string.Join(",",requiredChecks.Contexts)}).Information($"Found required checks"); 48 | return requiredChecks.Contexts.ToList(); 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /Miro/Services/Github/PrStatusChecks.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Net.Http; 4 | using System.Threading.Tasks; 5 | using Miro.Models.Github.RequestPayloads; 6 | using Miro.Models.Github.Responses; 7 | using Miro.Models.Merge; 8 | using Miro.Services.Comments; 9 | using Miro.Services.Logger; 10 | using Miro.Services.Merge; 11 | using Newtonsoft.Json; 12 | using Serilog; 13 | 14 | namespace Miro.Services.Github 15 | { 16 | public class PrStatusChecks 17 | { 18 | private readonly GithubHttpClient githubHttpClient; 19 | private readonly ILogger logger = Log.ForContext(); 20 | 21 | public PrStatusChecks(GithubHttpClient githubHttpClient, 22 | MergeRequestsRepository mergeRequestsRepository) 23 | { 24 | this.githubHttpClient = githubHttpClient; 25 | } 26 | public async Task UpdateStatusCheck(string owner, string repo, string shaOrBranch, string statusCheck, string state = "success") 27 | { 28 | var payload = new UpdateStatusCheckPayload() 29 | { 30 | State = state, 31 | Context = statusCheck, 32 | Description = CommentsConsts.MiroMergeCheckDescription, 33 | }; 34 | var uri = $"/repos/{owner}/{repo}/statuses/{shaOrBranch}"; 35 | logger.WithExtraData(new { owner, repo, shaOrBranch, uri, payload }).Information($"Making github update status check request"); 36 | 37 | var response = await githubHttpClient.Post(uri, payload); 38 | response.EnsureSuccessStatusCode(); 39 | } 40 | public async Task> GetStatusChecks(MergeRequest mergeRequest) 41 | { 42 | var owner = mergeRequest.Owner; 43 | var repo = mergeRequest.Repo; 44 | var sha = mergeRequest.Sha; 45 | 46 | var uri = $"/repos/{owner}/{repo}/statuses/{sha}"; 47 | logger.WithMergeRequestData(mergeRequest).Information($"Making github get status checks for PR request"); 48 | 49 | return await githubHttpClient.Get>(uri); 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /Miro.Tests/obj/Miro.Tests.csproj.nuget.g.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | True 5 | NuGet 6 | $(MSBuildThisFileDirectory)project.assets.json 7 | /Users/ore/.nuget/packages/ 8 | /Users/ore/.nuget/packages/ 9 | PackageReference 10 | 5.8.0 11 | 12 | 13 | 14 | 15 | 16 | $(MSBuildAllProjects);$(MSBuildThisFileFullPath) 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /Miro/Services/Github/PrUpdater.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net.Http; 3 | using System.Threading.Tasks; 4 | using Miro.Models.Github.RequestPayloads; 5 | using Miro.Models.Github.Responses; 6 | using Miro.Services.Logger; 7 | using Miro.Services.Merge; 8 | using Miro.Services.MiroConfig; 9 | using Newtonsoft.Json; 10 | using Serilog; 11 | 12 | namespace Miro.Services.Github 13 | { 14 | public class PrUpdater 15 | { 16 | private readonly GithubHttpClient githubHttpClient; 17 | 18 | private readonly RepoConfigManager repoConfigManager; 19 | 20 | private readonly ILogger logger = Log.ForContext(); 21 | 22 | public PrUpdater(GithubHttpClient githubHttpClient, RepoConfigManager repoConfigManager) 23 | { 24 | this.repoConfigManager = repoConfigManager; 25 | this.githubHttpClient = githubHttpClient; 26 | } 27 | public async Task UpdateBranch(string owner, string repo, string branch) 28 | { 29 | var defaultBranch = (await repoConfigManager.GetConfig(owner, repo)).DefaultBranch; 30 | 31 | if (branch == null) 32 | { 33 | throw new Exception($"Could not update branch for {owner}/{repo}, branch unknown"); 34 | } 35 | if (defaultBranch == null) 36 | { 37 | throw new Exception($"Could not update branch for {owner}/{repo}, head branch unknown"); 38 | } 39 | 40 | var payload = new UpdateBranchPayload() 41 | { 42 | CommitMessage = $"Merge branch {defaultBranch} into {branch}", 43 | Base = branch, 44 | Head = defaultBranch, 45 | }; 46 | string uri = $"/repos/{owner}/{repo}/merges"; 47 | logger.WithExtraData(new {owner, repo, branch, defaultBranch, uri}).Information($"Making github update branch request"); 48 | 49 | var response = await githubHttpClient.Post(uri, payload); 50 | 51 | try { 52 | response.EnsureSuccessStatusCode(); 53 | } catch (HttpRequestException e) 54 | { 55 | logger.WithExtraData(new {owner, repo, branch, uri}).Warning(e, $"Failed updating branch"); 56 | throw e; 57 | } 58 | 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /Miro/Services/Comments/CommentsConsts.cs: -------------------------------------------------------------------------------- 1 | namespace Miro.Services.Comments 2 | { 3 | public class CommentsConsts 4 | { 5 | public const string MergeCommand = "miro merge"; 6 | public const string CancelCommand = "miro cancel"; 7 | public const string InfoCommand = "miro info"; 8 | public const string WipCommand = "miro wip"; 9 | 10 | public const string MiroHeader = ":dog2: Miro says... :dog2:"; 11 | public const string Merging = "Merging:"; 12 | public const string MiroMergeCheckDescription = "Write 'miro merge' to resolve this"; 13 | public const string PullRequestCanNotBeMerged = "Ouch! Pull Request not Merged"; 14 | public const string UpdatingAForkNotAllowed = "Sorry, Miro doesn't know how to update a fork respository yet"; 15 | public const string TryToUpdateWithDefaultBranch = "I'll try to update branch with the default branch"; 16 | public const string CantUpdateBranchHeader = "Damn! Can't update branch"; 17 | public const string PrIsMergeableBody = "To merge this PR - type `miro merge`"; 18 | public const string CantUpdateBranchBody = "This is where miro gives up, but Miro will still be listening for changes on the PR"; 19 | public const string MiroInfoMergeNotReady = "Not ready for merging"; 20 | public const string MiroInfoMergeReady = "PR ready for merging"; 21 | public const string BlackListPullRequestHeader = "This Pull Request will be merged automatically by Miro"; 22 | public const string BlackListPullRequestWipHeader = "Miro won't merge this PR since it's titled with \"WIP\""; 23 | public const string BlackListPullRequestBody = "No need to type `miro merge`. \n If you do *not* want this PR merged automatically, let miro know by typing `miro wip`"; 24 | public const string BlackListPullRequestWipBody = "Type `miro merge` when you want Miro to merge it for you."; 25 | public const string MiroCancelHeader = "Cancelled"; 26 | public const string MiroCancelBody = "You told miro to cancel"; 27 | public const string MiroWipHeader = "Work in Progress, Copy that!"; 28 | public const string MiroWipBody = "Still working on this bad boy? \n Miro will hold off merging this Pull Request. \n When you're ready, type `miro merge`"; 29 | public const string MiroMergeCheckName = "Miro merge check"; 30 | } 31 | } -------------------------------------------------------------------------------- /Miro.Tests/MockGithubApi/index.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const bodyParser = require("body-parser"); 3 | const app = express(); 4 | const FakeServer = require("simple-fake-server").FakeServer; 5 | const uuidv4 = require("uuid/v4"); 6 | const escapeStringRegexp = require("escape-string-regexp"); 7 | 8 | fakeServer = new FakeServer(1234); 9 | fakeServer.start(); 10 | 11 | let mockedCalls = {}; 12 | 13 | app.use(bodyParser.json()); 14 | 15 | app.post("/fake_server_admin/calls", (req, res) => { 16 | const mockedMethod = req.body.method; 17 | const mockedUrl = req.body.url; 18 | const mockedReqBody = req.body.body; 19 | const mockedResponse = req.body.response; 20 | const isJson = req.body.isJson; 21 | const statusCode = req.body.statusCode; 22 | 23 | console.log( 24 | `Simple-Fake-Server got mock call to ${mockedMethod} ${mockedUrl} \n mocked Body : ${mockedReqBody}, mockedStatus: ${statusCode}` 25 | ) 26 | let callId = uuidv4(); 27 | let call; 28 | if (statusCode && statusCode !== 200) { 29 | call = fakeServer.http[mockedMethod]() 30 | .to(mockedUrl) 31 | .withBodyThatMatches(mockedReqBody ? `.*[${escapeStringRegexp(mockedReqBody)}].*` : '.*') 32 | .willFail(statusCode); 33 | } else { 34 | call = fakeServer.http[mockedMethod]() 35 | .to(mockedUrl) 36 | .withBodyThatMatches(mockedReqBody ? `.*[${escapeStringRegexp(mockedReqBody)}].*` : '.*') 37 | .willReturn(isJson ? JSON.parse(mockedResponse) : mockedResponse); 38 | } 39 | mockedCalls[callId] = call; 40 | res.send({ callId }); 41 | }); 42 | 43 | app.delete("/fake_server_admin/calls", (req, res) => { 44 | console.log( 45 | 'Got a request to clear all mocks' 46 | ) 47 | fakeServer.callHistory.clear(); 48 | res.send("Ok"); 49 | }); 50 | 51 | app.get("/fake_server_admin/calls", (req, res) => { 52 | const callId = req.query.callId; 53 | 54 | let hasBeenMade; 55 | 56 | const mockedCall = mockedCalls[callId] || { call: {} }; 57 | const madeCall = fakeServer.callHistory.calls.filter( 58 | c => 59 | c.method === mockedCall.call.method && 60 | new RegExp(mockedCall.call.pathRegex).test(c.path) 61 | )[0]; 62 | 63 | if (!mockedCall) { 64 | res.send({ hasBeenMade: false }); 65 | } else if (!madeCall) { 66 | res.send({ hasBeenMade: false }); 67 | } else { 68 | res.send({ hasBeenMade: true, details: madeCall }); 69 | } 70 | }); 71 | 72 | app.listen(3000, () => 73 | console.log( 74 | "simple fake server admin is on port 3000, mocked api is on port 1234" 75 | ) 76 | ); 77 | -------------------------------------------------------------------------------- /Miro/Services/Github/PrMerger.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net; 3 | using System.Net.Http; 4 | using System.Threading.Tasks; 5 | using Miro.Models.Github.RequestPayloads; 6 | using Miro.Models.Github.Responses; 7 | using Miro.Models.Merge; 8 | using Miro.Services.Logger; 9 | using Miro.Services.Merge; 10 | using Newtonsoft.Json; 11 | using Serilog; 12 | 13 | namespace Miro.Services.Github 14 | { 15 | public class PrMerger 16 | { 17 | private readonly GithubHttpClient githubHttpClient; 18 | private readonly ILogger logger = Log.ForContext(); 19 | 20 | public PrMerger(GithubHttpClient githubHttpClient) 21 | { 22 | this.githubHttpClient = githubHttpClient; 23 | } 24 | 25 | public async Task Merge(MergeRequest mergeRequest) 26 | { 27 | var owner = mergeRequest.Owner; 28 | var repo = mergeRequest.Repo; 29 | var prId = mergeRequest.PrId; 30 | var title = mergeRequest.Title; 31 | 32 | var payload = new MergePrPayload() 33 | { 34 | CommitMessage = $"Merging PR #{prId} - {title}", 35 | CommitTitle = $"{title} (Merging PR #{prId})", 36 | MergeMethod = "squash", 37 | }; 38 | 39 | string uri = $"/repos/{owner}/{repo}/pulls/{prId}/merge"; 40 | logger.WithMergeRequestData(mergeRequest).Information($"Making github merge request"); 41 | var response = await githubHttpClient.Put(uri, payload); 42 | 43 | MergePrResponse mergeResponse = new MergePrResponse(); 44 | try 45 | { 46 | var contentAsString = await response.Content.ReadAsStringAsync(); 47 | mergeResponse = JsonConvert.DeserializeObject(contentAsString); 48 | } 49 | catch (Exception) 50 | { 51 | } 52 | 53 | if (response.StatusCode == HttpStatusCode.Conflict || response.StatusCode == HttpStatusCode.MethodNotAllowed) 54 | { 55 | throw new PullRequestMismatchException(mergeResponse.Message); 56 | } 57 | if (response.StatusCode != HttpStatusCode.OK || mergeResponse == null || !mergeResponse.Merged) 58 | { 59 | var msg = mergeResponse != null ? mergeResponse.Message : "unknown"; 60 | var extraLogData = new {owner, repo, prId, uri, msg, responseStatus = response.StatusCode}; 61 | logger.WithExtraData(extraLogData).Error($"Failed Making github merge request"); 62 | throw new Exception(msg); 63 | } 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /Miro/Controllers/GithubWebhookController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Net.Http; 6 | using System.Net.Http.Headers; 7 | using System.Text; 8 | using System.Threading.Tasks; 9 | using Microsoft.AspNetCore.Mvc; 10 | using Microsoft.AspNetCore.WebHooks; 11 | using Miro.Models.Github.IncomingEvents; 12 | using Miro.Models.Github.Responses; 13 | using Miro.Services.Github; 14 | using Miro.Services.Github.EventHandlers; 15 | using Miro.Services.Logger; 16 | using Miro.Services.Merge; 17 | using Newtonsoft.Json.Linq; 18 | using Serilog; 19 | 20 | namespace Miro.Controllers 21 | { 22 | public class GithubWebhookController : ControllerBase 23 | { 24 | private readonly ILogger logger = Log.ForContext(); 25 | 26 | Dictionary>> handlers; 27 | 28 | public GithubWebhookController( 29 | IssueCommentEventHandler issueCommentEventHandler, 30 | PullRequestReviewEventHandler pullRequestReviewEventHandler, 31 | PullRequestEventHandler pullRequestEventHandler, 32 | PushEventHandler pushEventHandler, 33 | StatusEventHandler statusEventHandler) 34 | { 35 | handlers = new Dictionary>> { 36 | {"issue_comment", data => issueCommentEventHandler.Handle(data.ToObject())}, 37 | {"pull_request_review", data => pullRequestReviewEventHandler.Handle(data.ToObject())}, 38 | {"pull_request", data => pullRequestEventHandler.Handle(data.ToObject())}, 39 | {"status", data => statusEventHandler.Handle(data.ToObject())}, 40 | {"push", data => pushEventHandler.Handle(data.ToObject())}, 41 | }; 42 | } 43 | 44 | [GitHubWebHook] 45 | public async Task Post(string id, string @event, JObject data) 46 | { 47 | try 48 | { 49 | if (handlers.ContainsKey(@event)) 50 | { 51 | var res = await handlers[@event](data); 52 | return StatusCode(200, res); 53 | } 54 | 55 | return StatusCode(200); 56 | } 57 | catch (Exception e) 58 | { 59 | logger.WithExtraData(data).Error(e, $"Could not handle Event {@event}"); 60 | return StatusCode(500, "There was an error"); 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Miro.Tests/Helpers/MockCommentGithubCallHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Newtonsoft.Json; 4 | using static Miro.Tests.Helpers.GithubApiMock; 5 | using static Miro.Tests.Helpers.GithubUrlHelpers; 6 | 7 | namespace Miro.Tests.Helpers 8 | { 9 | public static class MockCommentGithubCallHelper 10 | { 11 | public static Task MockCommentGithubCall(string owner, string repo, int prId, string comment = null) 12 | { 13 | return MockGithubCall("post", $"{IssueUrlFor(owner,repo, prId)}/comments", comment, "ok", false); 14 | } 15 | 16 | public static Task MockCommentGithubCallMerging(string owner, string repo, int prId) 17 | { 18 | return MockCommentGithubCall(owner, repo, prId, "Merging"); 19 | } 20 | public static Task MockCommentGithubCallBlackListPrOpened(string owner, string repo, int prId) 21 | { 22 | return MockCommentGithubCall(owner, repo, prId, "This Pull Request will be merged automatically by Miro"); 23 | } 24 | public static Task MockCommentGithubCallMergeFailed(string owner, string repo, int prId) 25 | { 26 | return MockCommentGithubCall(owner, repo, prId, "Merge failed"); 27 | } 28 | 29 | public static Task MockCommentGithubCallCanNotUpdateBecauseFork(string owner, string repo, int prId) 30 | { 31 | return MockCommentGithubCall(owner, repo, prId, "Sorry, Miro doesn't know how to update a fork respository yet"); 32 | } 33 | 34 | public static Task MockCommentGithubCallCancel(string owner, string repo, int prId) 35 | { 36 | return MockCommentGithubCall(owner, repo, prId, "Cancel"); 37 | } 38 | 39 | public static Task MockCommentGithubCallWIP(string owner, string repo, int prId) 40 | { 41 | return MockCommentGithubCall(owner, repo, prId, "Work in Progress"); 42 | } 43 | 44 | public static Task MockCommentGithubPRIsReadyForMerging(string owner, string repo, int prId) 45 | { 46 | return MockCommentGithubCall(owner, repo, prId, "PR ready for merging"); 47 | } 48 | 49 | public static Task MockCommentGithubCallPendingReviews(string owner, string repo, int prId, string reviewer) 50 | { 51 | return MockCommentGithubCall(owner, repo, prId, reviewer); 52 | } 53 | 54 | public static Task MockCommentGithubCallPendingChecks(string owner, string repo, int prId) 55 | { 56 | return MockCommentGithubCall(owner, repo, prId, "Missing status checks"); 57 | } 58 | 59 | public static Task MockCommentGithubCallRequestedChanges(string owner, string repo, int prId, string changeRequestedBy) 60 | { 61 | return MockCommentGithubCall(owner, repo, prId, changeRequestedBy); 62 | } 63 | } 64 | } -------------------------------------------------------------------------------- /Miro.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.26124.0 5 | MinimumVisualStudioVersion = 15.0.26124.0 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Miro", "Miro\Miro.csproj", "{5DDD7FA4-DD4A-480A-A22E-BBA473C8A922}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Miro.Tests", "Miro.Tests\Miro.Tests.csproj", "{CFFF10AF-C6EE-4A36-834B-CDEA508D42F6}" 9 | EndProject 10 | Global 11 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 12 | Debug|Any CPU = Debug|Any CPU 13 | Debug|x64 = Debug|x64 14 | Debug|x86 = Debug|x86 15 | Release|Any CPU = Release|Any CPU 16 | Release|x64 = Release|x64 17 | Release|x86 = Release|x86 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 23 | {5DDD7FA4-DD4A-480A-A22E-BBA473C8A922}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 24 | {5DDD7FA4-DD4A-480A-A22E-BBA473C8A922}.Debug|Any CPU.Build.0 = Debug|Any CPU 25 | {5DDD7FA4-DD4A-480A-A22E-BBA473C8A922}.Debug|x64.ActiveCfg = Debug|Any CPU 26 | {5DDD7FA4-DD4A-480A-A22E-BBA473C8A922}.Debug|x64.Build.0 = Debug|Any CPU 27 | {5DDD7FA4-DD4A-480A-A22E-BBA473C8A922}.Debug|x86.ActiveCfg = Debug|Any CPU 28 | {5DDD7FA4-DD4A-480A-A22E-BBA473C8A922}.Debug|x86.Build.0 = Debug|Any CPU 29 | {5DDD7FA4-DD4A-480A-A22E-BBA473C8A922}.Release|Any CPU.ActiveCfg = Release|Any CPU 30 | {5DDD7FA4-DD4A-480A-A22E-BBA473C8A922}.Release|Any CPU.Build.0 = Release|Any CPU 31 | {5DDD7FA4-DD4A-480A-A22E-BBA473C8A922}.Release|x64.ActiveCfg = Release|Any CPU 32 | {5DDD7FA4-DD4A-480A-A22E-BBA473C8A922}.Release|x64.Build.0 = Release|Any CPU 33 | {5DDD7FA4-DD4A-480A-A22E-BBA473C8A922}.Release|x86.ActiveCfg = Release|Any CPU 34 | {5DDD7FA4-DD4A-480A-A22E-BBA473C8A922}.Release|x86.Build.0 = Release|Any CPU 35 | {CFFF10AF-C6EE-4A36-834B-CDEA508D42F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 36 | {CFFF10AF-C6EE-4A36-834B-CDEA508D42F6}.Debug|Any CPU.Build.0 = Debug|Any CPU 37 | {CFFF10AF-C6EE-4A36-834B-CDEA508D42F6}.Debug|x64.ActiveCfg = Debug|Any CPU 38 | {CFFF10AF-C6EE-4A36-834B-CDEA508D42F6}.Debug|x64.Build.0 = Debug|Any CPU 39 | {CFFF10AF-C6EE-4A36-834B-CDEA508D42F6}.Debug|x86.ActiveCfg = Debug|Any CPU 40 | {CFFF10AF-C6EE-4A36-834B-CDEA508D42F6}.Debug|x86.Build.0 = Debug|Any CPU 41 | {CFFF10AF-C6EE-4A36-834B-CDEA508D42F6}.Release|Any CPU.ActiveCfg = Release|Any CPU 42 | {CFFF10AF-C6EE-4A36-834B-CDEA508D42F6}.Release|Any CPU.Build.0 = Release|Any CPU 43 | {CFFF10AF-C6EE-4A36-834B-CDEA508D42F6}.Release|x64.ActiveCfg = Release|Any CPU 44 | {CFFF10AF-C6EE-4A36-834B-CDEA508D42F6}.Release|x64.Build.0 = Release|Any CPU 45 | {CFFF10AF-C6EE-4A36-834B-CDEA508D42F6}.Release|x86.ActiveCfg = Release|Any CPU 46 | {CFFF10AF-C6EE-4A36-834B-CDEA508D42F6}.Release|x86.Build.0 = Release|Any CPU 47 | EndGlobalSection 48 | EndGlobal 49 | -------------------------------------------------------------------------------- /Miro/Services/Checks/MiroMergeCheck.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using Microsoft.Extensions.Configuration; 7 | using Miro.Models.Checks; 8 | using Miro.Models.Github.Responses; 9 | using Miro.Models.Merge; 10 | using Miro.Services.Comments; 11 | using Miro.Services.Github; 12 | using Miro.Services.Github.EventHandlers; 13 | using Miro.Services.Logger; 14 | using Miro.Services.Merge; 15 | using Serilog; 16 | 17 | namespace Miro.Services.Checks 18 | { 19 | public class MiroMergeCheck 20 | { 21 | private readonly GithubHttpClient githubHttpClient; 22 | private readonly PrStatusChecks prStatusCheckUpdater; 23 | private readonly ILogger logger = Log.ForContext(); 24 | 25 | public MiroMergeCheck( 26 | GithubHttpClient githubHttpClient, 27 | PrStatusChecks prStatusCheckUpdater) 28 | { 29 | this.githubHttpClient = githubHttpClient; 30 | this.prStatusCheckUpdater = prStatusCheckUpdater; 31 | } 32 | 33 | public async Task AddMiroMergeRequiredCheck(MergeRequest mergeRequest) 34 | { 35 | var owner = mergeRequest.Owner; 36 | var repo = mergeRequest.Repo; 37 | var sha = mergeRequest.Sha; 38 | 39 | logger.WithMergeRequestData(mergeRequest).Information($"Creating pending Miro Merge check to a branch"); 40 | 41 | try 42 | { 43 | await prStatusCheckUpdater.UpdateStatusCheck(owner, repo, sha, CommentsConsts.MiroMergeCheckName, "pending"); 44 | } 45 | catch (Exception e) 46 | { 47 | logger.WithMergeRequestData(mergeRequest).Error(e, $"Could not resolve Miro Merge check to a branch"); 48 | } 49 | } 50 | 51 | public async Task ResolveMiroMergeCheck(MergeRequest mergeRequest) 52 | { 53 | var owner = mergeRequest.Owner; 54 | var repo = mergeRequest.Repo; 55 | var sha = mergeRequest.Sha; 56 | var branch = mergeRequest.Branch; 57 | 58 | logger.WithMergeRequestData(mergeRequest).Information($"Resolve Miro Merge check to a branch"); 59 | 60 | try 61 | { 62 | await prStatusCheckUpdater.UpdateStatusCheck(owner, repo, sha, CommentsConsts.MiroMergeCheckName); 63 | } 64 | catch (Exception e) 65 | { 66 | logger.WithMergeRequestData(mergeRequest).Error(e, $"Could not resolve Miro Merge check on sha, retrying with branch"); 67 | try 68 | { 69 | await prStatusCheckUpdater.UpdateStatusCheck(owner, repo, branch, CommentsConsts.MiroMergeCheckName); 70 | } 71 | catch (Exception er) 72 | { 73 | logger.WithMergeRequestData(mergeRequest).Error(er, $"Could not resolve Miro Merge check on branch"); 74 | } 75 | } 76 | } 77 | } 78 | } -------------------------------------------------------------------------------- /Miro/Services/Github/GithubHttpClient.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Net.Http; 4 | using System.Net.Http.Formatting; 5 | using System.Text; 6 | using System.Threading.Tasks; 7 | using GitHubJwt; 8 | using Microsoft.Extensions.Configuration; 9 | using Microsoft.Extensions.Logging; 10 | using Miro.Services.Auth; 11 | using Newtonsoft.Json; 12 | 13 | namespace Miro.Services.Github 14 | { 15 | public class GithubHttpClient 16 | { 17 | private readonly InstallationTokenStore tokenStore; 18 | private string githubInstallationId; 19 | private HttpClient httpClient; 20 | 21 | public GithubHttpClient(IConfiguration configuration, InstallationTokenStore tokenStore) 22 | { 23 | var githubApiUrl = configuration.GetValue("GITHUB_API_URL", "https://api.github.com/"); 24 | httpClient = new HttpClient() { BaseAddress = new Uri(githubApiUrl) }; 25 | this.tokenStore = tokenStore; 26 | } 27 | 28 | public async Task Get(string uri) 29 | { 30 | var request = await CreateGithubHttpRequest(uri); 31 | var response = await httpClient.SendAsync(request); 32 | response.EnsureSuccessStatusCode(); 33 | return await response.Content.ReadAsAsync(); 34 | } 35 | 36 | public async Task Post(string uri, object payload) 37 | { 38 | var request = await CreateGithubHttpRequest(uri); 39 | request.Method = HttpMethod.Post; 40 | request.Content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json"); 41 | 42 | return await httpClient.SendAsync(request); 43 | } 44 | 45 | public async Task Delete(string uri, object payload = null) 46 | { 47 | var request = await CreateGithubHttpRequest(uri); 48 | request.Method = HttpMethod.Delete; 49 | if ( payload != null ) request.Content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json"); 50 | return await httpClient.SendAsync(request); 51 | } 52 | 53 | public async Task Put(string uri, object payload) 54 | { 55 | var request = await CreateGithubHttpRequest(uri); 56 | request.Method = HttpMethod.Put; 57 | request.Content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json"); 58 | 59 | return await httpClient.SendAsync(request); 60 | } 61 | 62 | private async Task CreateGithubHttpRequest(string uri) 63 | { 64 | var accessToken = await tokenStore.GetToken(); 65 | var httpRequest = new HttpRequestMessage() { RequestUri = new Uri(uri, UriKind.Relative) }; 66 | httpRequest.Headers.Add("Authorization", new List { $"token {accessToken}" }); 67 | httpRequest.Headers.Add("User-Agent", new List { "Miro" }); 68 | 69 | return httpRequest; 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /Miro/Services/MiroConfig/RepoConfigManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Miro.Models.Github.Entities; 4 | using Miro.Models.MiroConfig; 5 | using Miro.Services.Github; 6 | using Miro.Services.Logger; 7 | using MiroConfig; 8 | using Serilog; 9 | 10 | namespace Miro.Services.MiroConfig 11 | { 12 | public class RepoConfigManager 13 | { 14 | private readonly ILogger logger = Log.ForContext(); 15 | private readonly FileRetriever fileRetriever; 16 | private readonly RepoConfigRepository repoConfigRepository; 17 | private readonly string REPO_CONFIG_FILE_NAME = ".miro.yml"; 18 | 19 | public RepoConfigManager( 20 | FileRetriever fileRetriever, 21 | RepoConfigRepository repoConfigRepository 22 | ) 23 | { 24 | this.fileRetriever = fileRetriever; 25 | this.repoConfigRepository = repoConfigRepository; 26 | } 27 | 28 | public async Task UpdateConfig(string owner, string repo) 29 | { 30 | var content = await FetchConfigFromGithub(owner, repo); 31 | logger.WithExtraData(new {content}).Information("Updating Repo with new Repo Config"); 32 | await repoConfigRepository.Update(content); 33 | return content; 34 | } 35 | 36 | public async Task GetConfig(string owner, string repo) 37 | { 38 | var result = await repoConfigRepository.Get(owner, repo); 39 | 40 | if (result == null) 41 | { 42 | result = await FetchConfigFromGithub(owner, repo); 43 | await repoConfigRepository.Create(result); 44 | } 45 | return result; 46 | 47 | } 48 | 49 | private async Task FetchConfigFromGithub(string owner, string repo) 50 | { 51 | var file = await fileRetriever.GetFile(owner, repo, REPO_CONFIG_FILE_NAME); 52 | 53 | if (file == null) 54 | { 55 | logger.WithExtraData(new {owner, repo}).Warning("No config file found for repo"); 56 | return new RepoConfig 57 | { 58 | Repo = repo, 59 | Owner = owner 60 | }; 61 | } 62 | var decoded = file.DecodeContent(); 63 | 64 | decoded.Owner = owner; 65 | decoded.Repo = repo; 66 | decoded.UpdatedAt = DateTime.UtcNow; 67 | 68 | if (!decoded.IsValidMergePolicy()) 69 | { 70 | decoded.MergePolicy = "whitelist"; 71 | logger.WithExtraData(new {decoded}).Warning("Invalid miro yml configuration given for merge policy"); 72 | } 73 | if (!decoded.IsValidUpdateBranchStrategy()) 74 | { 75 | decoded.UpdateBranchStrategy = "oldest"; 76 | logger.WithExtraData(new {decoded}).Warning("Invalid miro yml configuration given for update branch strategy"); 77 | } 78 | 79 | return decoded; 80 | } 81 | } 82 | } -------------------------------------------------------------------------------- /Miro/Services/Github/EventHandlers/PullRequestReviewEventHandler.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Miro.Models.Github.IncomingEvents; 4 | using Miro.Models.Github.Responses; 5 | using Miro.Services.Logger; 6 | using Miro.Services.Merge; 7 | using Serilog; 8 | 9 | namespace Miro.Services.Github.EventHandlers 10 | { 11 | public class PullRequestReviewEventHandler : IWebhookEventHandler 12 | { 13 | private readonly MergeRequestsRepository mergeRequestsRepository; 14 | private readonly MergeOperations mergeOperations; 15 | private readonly CommentCreator commentCreator; 16 | private readonly ILogger logger = Log.ForContext(); 17 | 18 | public PullRequestReviewEventHandler( 19 | MergeRequestsRepository mergeRequestsRepository, 20 | MergeOperations mergeOperations, 21 | CommentCreator commentCreator) 22 | { 23 | this.mergeRequestsRepository = mergeRequestsRepository; 24 | this.mergeOperations = mergeOperations; 25 | this.commentCreator = commentCreator; 26 | } 27 | 28 | public async Task Handle(PullRequestReviewEvent payload) 29 | { 30 | var submittedAction = payload.Action.Equals("submitted", StringComparison.OrdinalIgnoreCase); 31 | if (!submittedAction) 32 | { 33 | logger.WithExtraData(new {payloadAction = payload.Action}).Information("Received Review event on action other than 'submitted', ignoring"); 34 | return new WebhookResponse(false, "Received Review event on action other than 'submitted', ignoring"); 35 | } 36 | 37 | var isApprovedState = payload.Review.State.Equals("approved", StringComparison.OrdinalIgnoreCase); 38 | if (!isApprovedState) 39 | { 40 | logger.WithExtraData(new {payloadAction = payload.Action, reviewState = payload.Review.State}).Information($"Received submit Review event on event other than 'approved', ignoring"); 41 | return new WebhookResponse(false, "Received submit Review event on action other than 'submitted', ignoring"); 42 | } 43 | 44 | var owner = payload.Repository.Owner.Login; 45 | var repo = payload.Repository.Name; 46 | var prId = payload.PullRequest.Number; 47 | 48 | var mergeRequest = await mergeRequestsRepository.Get(owner, repo, prId); 49 | if (mergeRequest == null) 50 | { 51 | logger.WithExtraData(new {owner, repo, prId}).Warning("Received Review event on unknown PR, Miro can't handle this"); 52 | return new WebhookResponse(false, "Received Review event on unknown PR, Miro can't handle this"); 53 | } 54 | 55 | logger.WithMergeRequestData(mergeRequest).Information("Received Approved Review event on PR, Trying to merge"); 56 | var merged = await mergeOperations.TryToMerge(mergeRequest); 57 | return new WebhookResponse(true, $"Received Approved Review event on PR, did branch merge: {merged}"); 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /Miro/Services/Github/CommentCreator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using Miro.Models.Github.RequestPayloads; 7 | using Miro.Services.Comments; 8 | using Miro.Services.Logger; 9 | using Newtonsoft.Json; 10 | using Serilog; 11 | 12 | namespace Miro.Services.Github 13 | { 14 | public class CommentCreator 15 | { 16 | private readonly GithubHttpClient githubHttpClient; 17 | private readonly ILogger logger = Log.ForContext(); 18 | 19 | public CommentCreator(GithubHttpClient githubHttpClient) 20 | { 21 | this.githubHttpClient = githubHttpClient; 22 | } 23 | 24 | public async Task CreateComment(string owner, string repo, int prId, string commentHeader, params string[] commentBody) 25 | { 26 | logger.WithExtraData(new { owner, repo, prId, commentHeader }).Information($"Creating comment"); 27 | var prettyComment = BuildMarkdownComment(commentHeader, commentBody); 28 | await GithubRequest(owner, repo, prId, prettyComment); 29 | } 30 | 31 | 32 | public async Task CreateListedComment(string owner, string repo, int prId, string commentHeader, List commentList = null) 33 | { 34 | logger.WithExtraData(new {owner, repo, prId, commentHeader}).Information($"Creating comment"); 35 | var prettyComment = BuildMarkdownListedComment(commentHeader, commentList); 36 | await GithubRequest(owner, repo, prId, prettyComment); 37 | } 38 | 39 | private string BuildMarkdownComment(string header, params string[] commentBody) 40 | { 41 | var stringBuilder = MarkdownHeader(header); 42 | 43 | if (commentBody != null && commentBody.Any()) 44 | { 45 | foreach (var line in commentBody) 46 | { 47 | stringBuilder 48 | .AppendLine("") 49 | .AppendLine(line); 50 | } 51 | } 52 | return stringBuilder.ToString(); 53 | } 54 | 55 | private string BuildMarkdownListedComment(string header, List commentBody) 56 | { 57 | var stringBuilder = MarkdownHeader(header); 58 | 59 | if (commentBody != null && commentBody.Any()) 60 | { 61 | commentBody.ForEach(e => stringBuilder.AppendLine($"- #### {e}")); 62 | } 63 | return stringBuilder.ToString(); 64 | } 65 | 66 | private async Task GithubRequest(string owner, string repo, int prId, string prettyComment) 67 | { 68 | var url = $"/repos/{owner}/{repo}/issues/{prId}/comments"; 69 | var response = await githubHttpClient.Post(url, new CreateCommentPayload { Body = prettyComment }); 70 | response.EnsureSuccessStatusCode(); 71 | } 72 | 73 | private static StringBuilder MarkdownHeader(string header) 74 | { 75 | var stringBuilder = new StringBuilder(); 76 | stringBuilder.AppendLine(CommentsConsts.MiroHeader) 77 | .AppendLine("") 78 | .AppendLine($"## {header}") 79 | .AppendLine(""); 80 | return stringBuilder; 81 | } 82 | } 83 | } -------------------------------------------------------------------------------- /docs/DEPLOYING.md: -------------------------------------------------------------------------------- 1 | # Deploying Miro 2 | 3 | 4 | 5 | - [Deploying Miro](#deploying-miro) 6 | - [Running Locally against Github](#running-locally-against-github) 7 | - [env vars](#env-vars) 8 | - [Miro Badge](#miro-badge) 9 | 10 | 11 | 12 | ## Running Locally against Github 13 | 14 | 1. Create a new [Github App](https://developer.github.com/apps/building-github-apps/creating-a-github-app/) 15 | 2. Give the app the following permssions: 16 | - Repository administration: Read-only 17 | - Checks: Read-only 18 | - Repository contents: Read & write 19 | - Issues: Read & write 20 | - Repository metadata: Read-only 21 | - Pull requests: Read & write 22 | - Repository webhooks: Read-only 23 | - Commit statuses: Read & write 24 | - Organization hooks: Read-only 25 | 26 | 3. Give the app the following webhook subscriptions 27 | - Commit comment 28 | - Issue comment 29 | - Pull request 30 | - Pull request review 31 | - Push 32 | - Pull request review comment 33 | - Status 34 | 35 | 4. Generate a [private key](https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/#generating-a-private-key) for the github app 36 | 37 | 5. Save the private key in a private place, for example `./secrets/github-private-key.pem` 38 | *Note* We will be using the body of the pem: `-----BEGIN RSA PRIVATE KEY----- -----END RSA PRIVATE KEY-----` 39 | 40 | 6. Add your new app to a repo you want Miro to work on 41 | 42 | 7. Extract your app's installation id as explained [here](https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/#authenticating-as-a-github-app), we'll call this env var `GITHUB_INSTALLATION_ID` 43 | 44 | 8. In your github app's page, generate some secret key in the `Webhook secret (optional)` option. 45 | 46 | 9. Start a mongodb instance, for example `docker run --name a-mongo -p 27017:27017 -d mongo` 47 | 48 | 10. Start a tunnel (for example [ngrok](https://ngrok.com/)) on port `5000` 49 | 50 | 11. In your App general settings, change the `Webhook URL` to `https:///api/webhooks/incoming/github` 51 | 52 | 12. Pull + Run the `soluto-miro` docker image: 53 | 54 | ```sh 55 | docker run \ 56 | -e "MONGO_CONNECTION_STRING=" \ 57 | -e "ASPNETCORE_ENVIRONMENT=Production" \ 58 | -e "WEBHOOKS__DISABLEHTTPSCHECK=true" \ 59 | -e "WEBHOOKS__GITHUB__SECRETKEY__DEFAULT=" \ 60 | -e "GITHUB_INSTALLATION_ID=" \ 61 | -e "GITHUB_PEM_SECRET=" \ 62 | -p 5000:80 \ 63 | soluto-miro 64 | ``` 65 | 66 | 13. Create a Pull-request in your repo, and type `miro info` 67 | 68 | 69 | ## env vars 70 | - MONGO_CONNECTION_STRING: *Required* - Your mongo connection string 71 | - GITHUB_PEM_SECRET: *Required* - 72 | - GITHUB_INSTALLATION_ID: *Required* - 73 | - WEBHOOKS__GITHUB__SECRETKEY__DEFAULT: *Required* 74 | - WEBHOOKS__DISABLEHTTPSCHECK: *Required* 75 | - API_KEY: *Optional* - If you'd like to protect the Miro API with a hard-coded api-key 76 | 77 | ## Miro Badge 78 | Show developers this Repo's rockin Miro, put this shield at the top of your repo - 79 | 80 | [![Miro](https://img.shields.io/badge/Merge--Bot-Miro-green.svg)](https://github.com/Soluto/Miro) 81 | 82 | 83 | ```md 84 | [![Miro](https://img.shields.io/badge/Merge--Bot-Miro-green.svg)](https://github.com/Soluto/Miro) 85 | 86 | ``` -------------------------------------------------------------------------------- /Miro/Services/Checks/ChecksManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using Microsoft.Extensions.Configuration; 7 | using Miro.Models.Checks; 8 | using Miro.Models.Github.RequestPayloads; 9 | using Miro.Models.Github.Responses; 10 | using Miro.Models.Merge; 11 | using Miro.Services.Github; 12 | using Miro.Services.Github.EventHandlers; 13 | using Miro.Services.Logger; 14 | using Miro.Services.Merge; 15 | using Serilog; 16 | 17 | namespace Miro.Services.Checks 18 | { 19 | public class ChecksManager 20 | { 21 | private readonly MergeRequestsRepository mergeRequestsRepository; 22 | private readonly ChecksRepository checksRepository; 23 | private readonly ChecksRetriever checksRetriever; 24 | private readonly ILogger logger = Log.ForContext(); 25 | 26 | 27 | public ChecksManager( 28 | MergeRequestsRepository mergeRequestsRepository, 29 | ChecksRepository checksRepository, 30 | ChecksRetriever checksRetriever) 31 | { 32 | this.mergeRequestsRepository = mergeRequestsRepository; 33 | this.checksRepository = checksRepository; 34 | this.checksRetriever = checksRetriever; 35 | } 36 | 37 | public async Task> UpdateChecks(string owner, string repo) 38 | { 39 | var checks = await checksRetriever.GetRequiredChecks(owner, repo); 40 | 41 | if (checks == null) 42 | return new List(); 43 | 44 | logger.WithExtraData(new {owner, repo, checks = string.Join(",", checks)}).Information($"Receieved a call to Update checks"); 45 | await checksRepository.Update(owner, repo, checks); 46 | return checks; 47 | } 48 | 49 | public async Task> GetMissingChecks(string owner, string repo, List requestChecks) 50 | { 51 | var checksFromRepo = await GetRequiredChecks(owner, repo); 52 | return CompareChecks(requestChecks, checksFromRepo); 53 | } 54 | 55 | public async Task IsRequiredCheck(string owner, string repo, string checkName) 56 | { 57 | var checks = await GetRequiredChecks(owner, repo); 58 | return checks.Any(x => x == checkName); 59 | } 60 | 61 | public async Task> FilterNonRequiredChecks(string owner, string repo, List requestChecks) 62 | { 63 | var checks = await GetRequiredChecks(owner, repo); 64 | return requestChecks.Where(reqCheck => checks.Contains(reqCheck.Context)).ToList(); 65 | } 66 | 67 | 68 | private static List CompareChecks(List requestChecks, List checks) 69 | { 70 | var finalChecks = new List(); 71 | if (checks != null) finalChecks.AddRange(checks); 72 | return finalChecks.Where(check => !requestChecks.Any(x => x.Name == check && x.Status == "success")).ToList(); 73 | } 74 | 75 | private async Task> GetRequiredChecks(string owner, string repo) 76 | { 77 | var checksFromDb = await checksRepository.Get(owner, repo); 78 | 79 | if (checksFromDb != null) 80 | { 81 | return checksFromDb.CheckNames; 82 | } 83 | logger.WithExtraData(new { owner, repo }).Information($"No checks found in DB, fetching file from repository"); 84 | var checksFromGithub = await checksRetriever.GetRequiredChecks(owner, repo); 85 | 86 | if (checksFromGithub == null) 87 | { 88 | logger.WithExtraData(new { owner, repo }).Error($"No required checks found in repo"); 89 | return new List(); 90 | } 91 | 92 | await checksRepository.Update(owner, repo, checksFromGithub); 93 | return checksFromGithub; 94 | } 95 | } 96 | } -------------------------------------------------------------------------------- /Miro/Services/Auth/InstallationTokenStore.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net.Http; 3 | using System.Reactive; 4 | using System.Reactive.Concurrency; 5 | using System.Reactive.Linq; 6 | using System.Reactive.Subjects; 7 | using System.Text; 8 | using System.Threading.Tasks; 9 | using GitHubJwt; 10 | using Microsoft.Extensions.Configuration; 11 | using Microsoft.Extensions.Logging; 12 | using Newtonsoft.Json; 13 | 14 | namespace Miro.Services.Auth 15 | { 16 | public class InstallationTokenStore : IDisposable 17 | { 18 | private IConnectableObservable tokenObservable; 19 | private IDisposable subscription; 20 | 21 | public InstallationTokenStore(IConfiguration configuration) 22 | { 23 | var isProductionEnv = configuration.GetValue("ASPNETCORE_ENVIRONMENT").Equals("Production", StringComparison.OrdinalIgnoreCase); 24 | var pemVar = configuration.GetValue("GITHUB_PEM_SECRET", "nothing"); 25 | 26 | if (isProductionEnv && pemVar == "nothing" || string.IsNullOrEmpty(pemVar)) 27 | { 28 | throw new ArgumentException("You must provide a pem file path when running in production"); 29 | } 30 | if (pemVar == "nothing" || string.IsNullOrEmpty(pemVar)) 31 | { 32 | tokenObservable = new string[] { "dummy token" }.ToObservable().Replay(1); 33 | } 34 | else 35 | { 36 | var githubInstallationId = configuration["GITHUB_INSTALLATION_ID"]; 37 | tokenObservable = Observable.Interval(TimeSpan.FromMinutes(55)) 38 | .StartWith(0) 39 | .Select(l => Unit.Default) 40 | .ObserveOn(TaskPoolScheduler.Default) 41 | .SelectMany(_ => GetInstallationToken(pemVar, githubInstallationId)) 42 | .Replay(1); 43 | 44 | } 45 | 46 | subscription = tokenObservable.Connect(); 47 | } 48 | 49 | public void Dispose() 50 | { 51 | subscription?.Dispose(); 52 | } 53 | 54 | public async Task GetToken() 55 | { 56 | return await tokenObservable.FirstAsync(); 57 | } 58 | 59 | private async Task GetInstallationToken(string pemData, string githubInstallationId) 60 | { 61 | var generator = new GitHubJwt.GitHubJwtFactory(new StringPrivateKeySource(pemData), 62 | new GitHubJwtFactoryOptions 63 | { 64 | AppIntegrationId = 15348, 65 | ExpirationSeconds = 600 // 10 minutes is the maximum time allowed 66 | }); 67 | 68 | var jwtToken = generator.CreateEncodedJwtToken(); 69 | var httpClient = new HttpClient() { BaseAddress = new Uri("https://api.github.com/") }; 70 | 71 | var installationTokenRequest = new HttpRequestMessage 72 | { 73 | RequestUri = new Uri($"/app/installations/{githubInstallationId}/access_tokens", UriKind.Relative), 74 | Method = HttpMethod.Post 75 | }; 76 | 77 | installationTokenRequest.Headers.Add("Authorization", $"Bearer {jwtToken}"); 78 | installationTokenRequest.Headers.Add("User-Agent", $"Miro"); 79 | installationTokenRequest.Headers.Add("Accept", $"application/vnd.github.machine-man-preview+json"); 80 | 81 | var tokenResponse = await httpClient.SendAsync(installationTokenRequest); 82 | var responseString = await tokenResponse.Content.ReadAsStringAsync(); 83 | var responseJson = JsonConvert.DeserializeObject(responseString); 84 | 85 | return responseJson["token"]; 86 | } 87 | } 88 | } -------------------------------------------------------------------------------- /Miro.Tests/Helpers/MergeRequestsCollection.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System; 4 | using System.Threading.Tasks; 5 | using MongoDB.Bson; 6 | using MongoDB.Driver; 7 | 8 | namespace Miro.Tests.Helpers 9 | { 10 | public class MergeRequestsCollection 11 | { 12 | private static string MongoUrl = Environment.GetEnvironmentVariable("MONGO_CONNECTION_STRING"); 13 | 14 | public MergeRequestsCollection() 15 | { 16 | var client = new MongoClient(MongoUrl ?? "mongodb://localhost:27017"); 17 | var database = client.GetDatabase("miro-db"); 18 | this.Collection = database.GetCollection("merge-requests"); 19 | } 20 | 21 | public IMongoCollection Collection { get; } 22 | 23 | public async Task Insert(string owner, string repo, int prId, string branch = "some-branch", bool receivedMergeCommand = false, IEnumerable Checks = null, string sha = null, bool isFork = false) 24 | { 25 | var existingMergeRequest = new BsonDocument(); 26 | existingMergeRequest["Owner"] = owner; 27 | existingMergeRequest["Repo"] = repo; 28 | existingMergeRequest["PrId"] = prId; 29 | existingMergeRequest["Branch"] = branch ?? "some-branch"; 30 | existingMergeRequest["ReceivedMergeCommand"] = receivedMergeCommand; 31 | existingMergeRequest["IsFork"] = isFork; 32 | 33 | if (sha != null) 34 | { 35 | existingMergeRequest["Sha"] = sha; 36 | } 37 | 38 | if (Checks != null) { 39 | var BsonChecks = Checks.Select(x => new BsonDocument {{ "Name", x.Name },{ "Status", x.Status }}); 40 | existingMergeRequest["Checks"] = new BsonArray().AddRange(BsonChecks); 41 | } 42 | 43 | await Collection.InsertOneAsync(existingMergeRequest); 44 | } 45 | 46 | public async Task UpdateMergeRequest(string owner, string repo, int prId, string key, object value) 47 | { 48 | var update = Builders.Update 49 | .Set(r => r[key], value); 50 | await Collection.FindOneAndUpdateAsync(r => r["Owner"] == owner && 51 | r["Repo"] == repo && 52 | r["PrId"] == prId, update); 53 | } 54 | 55 | public Task UpdateMergeRequest(string owner, string repo, int prId, bool receivedMergeCommand, DateTime mergeCommandTime) 56 | { 57 | return Task.WhenAll( 58 | UpdateMergeRequest(owner, repo, prId, "ReceivedMergeCommand", receivedMergeCommand), 59 | UpdateMergeRequest(owner, repo, prId, "ReceivedMergeCommandTimestamp", mergeCommandTime)); 60 | } 61 | 62 | public async Task InsertWithTestChecksSuccessAndMergeCommand(string owner, string repo, int prId, string branch = "some-branch", string sha = null, bool isFork = false) 63 | { 64 | await Insert(owner, repo, prId, branch, true, new List(){ 65 | new CheckStatus { 66 | Name = Consts.TEST_CHECK_A, 67 | Status = "success" 68 | }, 69 | new CheckStatus { 70 | Name = Consts.TEST_CHECK_B, 71 | Status = "success" 72 | } 73 | }, sha, isFork); 74 | } 75 | 76 | public async Task InsertWithTestChecksSuccess(string owner, string repo, int prId, string branch = "some-branch", string sha = null, bool isFork = false) 77 | { 78 | await Insert(owner, repo, prId, branch, false, new List(){ 79 | new CheckStatus { 80 | Name = Consts.TEST_CHECK_A, 81 | Status = "success" 82 | }, 83 | new CheckStatus { 84 | Name = Consts.TEST_CHECK_B, 85 | Status = "success" 86 | } 87 | }, sha, isFork); 88 | } 89 | } 90 | } -------------------------------------------------------------------------------- /Miro.Tests/IssueInfoCommentEventProcessingTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Net; 5 | using System.Net.Http; 6 | using System.Security.Cryptography; 7 | using System.Text; 8 | using System.Threading.Tasks; 9 | using Miro.Tests.Helpers; 10 | using MongoDB.Bson; 11 | using MongoDB.Driver; 12 | using Newtonsoft.Json; 13 | using Xunit; 14 | using static Miro.Tests.Helpers.GithubApiMock; 15 | using static Miro.Tests.Helpers.WebhookRequestSender; 16 | using static Miro.Tests.Helpers.GithubUrlHelpers; 17 | 18 | namespace Miro.Tests 19 | { 20 | public class IssueInfoCommentEventProcessingTests 21 | { 22 | const int PR_ID = 7; 23 | 24 | private readonly MergeRequestsCollection mergeRequestsCollection; 25 | private readonly CheckListsCollection checkListsCollection; 26 | 27 | 28 | public IssueInfoCommentEventProcessingTests() 29 | { 30 | this.mergeRequestsCollection = new MergeRequestsCollection(); 31 | this.checkListsCollection = new CheckListsCollection(); 32 | } 33 | 34 | [Fact] 35 | public async Task ReceiveInfoCommand_AllChecksPassed_WriteSuccessComment() 36 | { 37 | var payloadString = await File.ReadAllTextAsync("../../../DummyEvents/IssueComment.json"); 38 | var payload = JsonConvert.DeserializeObject(payloadString); 39 | 40 | var owner = Guid.NewGuid().ToString(); 41 | var repo = Guid.NewGuid().ToString(); 42 | 43 | // Insert Checkslist and PR to DB 44 | await checkListsCollection.InsertWithDefaultChecks(owner, repo); 45 | await mergeRequestsCollection.InsertWithTestChecksSuccess(owner, repo, PR_ID); 46 | 47 | payload["repository"]["name"] = repo; 48 | payload["repository"]["owner"]["login"] = owner; 49 | payload["issue"]["number"] = PR_ID; 50 | payload["comment"]["body"] = "Miro info"; 51 | 52 | // Mock Github Calls 53 | var successCommentCallId = await MockCommentGithubCallHelper.MockCommentGithubPRIsReadyForMerging(owner, repo, PR_ID); 54 | await MockReviewGithubCallHelper.MockAllReviewsPassedResponses(owner, repo, PR_ID); 55 | 56 | // ACTION 57 | await SendWebhookRequest("issue_comment", JsonConvert.SerializeObject(payload)); 58 | 59 | // ASSERT 60 | var successCommentCall = await GetCall(successCommentCallId); 61 | Assert.True(successCommentCall.HasBeenMade, "Should have recieved a - PR is ready for merging comment"); 62 | } 63 | 64 | 65 | [Fact] 66 | public async Task ReceiveInfoCommand_AllChecksPassed_PrHasPendingReviews_WriteErrorComment() 67 | { 68 | var payloadString = await File.ReadAllTextAsync("../../../DummyEvents/IssueComment.json"); 69 | var payload = JsonConvert.DeserializeObject(payloadString); 70 | 71 | var owner = Guid.NewGuid().ToString(); 72 | var repo = Guid.NewGuid().ToString(); 73 | 74 | // Insert Checkslist and PR to DB 75 | await checkListsCollection.InsertWithDefaultChecks(owner, repo); 76 | await mergeRequestsCollection.InsertWithTestChecksSuccess(owner, repo, PR_ID); 77 | 78 | payload["repository"]["name"] = repo; 79 | payload["repository"]["owner"]["login"] = owner; 80 | payload["issue"]["number"] = PR_ID; 81 | payload["comment"]["body"] = "Miro info"; 82 | 83 | var requestedReviewsMockedResponse = new 84 | { 85 | teams = Array.Empty(), 86 | users = new[] { new { login = "itay", id = 3 } } 87 | }; 88 | 89 | // Mock Github Calls 90 | await MockReviewGithubCallHelper.MockReviewsResponses(JsonConvert.SerializeObject(requestedReviewsMockedResponse), "[]", owner, repo, PR_ID); 91 | var failureCommentCallId = await MockCommentGithubCallHelper.MockCommentGithubCallPendingReviews(owner, repo, PR_ID, "itay"); 92 | 93 | // ACTION 94 | await SendWebhookRequest("issue_comment", JsonConvert.SerializeObject(payload)); 95 | 96 | // ASSERT 97 | var failureCommentCall = await GetCall(failureCommentCallId); 98 | Assert.True(failureCommentCall.HasBeenMade, "Should have recieved a failure comment"); 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Miro.Tests/IssueCancelCommentEventProcessingTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Net; 5 | using System.Net.Http; 6 | using System.Security.Cryptography; 7 | using System.Text; 8 | using System.Threading.Tasks; 9 | using Miro.Tests.Helpers; 10 | using MongoDB.Bson; 11 | using MongoDB.Driver; 12 | using Newtonsoft.Json; 13 | using Xunit; 14 | using static Miro.Tests.Helpers.WebhookRequestSender; 15 | using static Miro.Tests.Helpers.GithubApiMock; 16 | using static Miro.Tests.Helpers.GithubUrlHelpers; 17 | namespace Miro.Tests 18 | { 19 | public class IssueCancelCommentEventProcessingTests 20 | { 21 | const int PR_ID = 6; 22 | 23 | private MergeRequestsCollection mergeRequestsCollection; 24 | 25 | public IssueCancelCommentEventProcessingTests() 26 | { 27 | this.mergeRequestsCollection = new MergeRequestsCollection(); 28 | } 29 | 30 | [Fact] 31 | public async Task ReceiveCancelCommand_PrExists_MergeCommandIsRemoved() 32 | { 33 | var owner = Guid.NewGuid().ToString(); 34 | var repo = Guid.NewGuid().ToString(); 35 | 36 | var payloadString = await File.ReadAllTextAsync("../../../DummyEvents/IssueComment.json"); 37 | var payload = JsonConvert.DeserializeObject(payloadString); 38 | 39 | // Issue cancel comment 40 | payload["repository"]["name"] = repo; 41 | payload["repository"]["owner"]["login"] = owner; 42 | payload["issue"]["number"] = PR_ID; 43 | payload["comment"]["body"] = "miro cancel"; 44 | 45 | // Insert PR 46 | await mergeRequestsCollection.Insert(owner, repo, PR_ID); 47 | 48 | // Update with MergeCommand 49 | await mergeRequestsCollection.UpdateMergeRequest(owner, repo, PR_ID, true, DateTime.UtcNow); 50 | 51 | // Mock Comments 52 | var createCommentCallId = await MockCommentGithubCallHelper.MockCommentGithubCallCancel(owner, repo, PR_ID); 53 | 54 | // Action 55 | await SendWebhookRequest("issue_comment", JsonConvert.SerializeObject(payload)); 56 | 57 | // Assert 58 | var mergeRequest = await mergeRequestsCollection.Collection.Find(d => d["Owner"] == owner && d["Repo"] == repo && d["PrId"] == PR_ID).FirstOrDefaultAsync(); 59 | Assert.NotNull(mergeRequest); 60 | 61 | Assert.False((bool) mergeRequest["ReceivedMergeCommand"]); 62 | Assert.True(mergeRequest["ReceivedMergeCommandTimestamp"] == DateTime.MaxValue); 63 | 64 | var createCommentCall = await GetCall(createCommentCallId); 65 | Assert.True(createCommentCall.HasBeenMade, "a cancel comment should have been posted to the pr"); 66 | } 67 | [Fact] 68 | public async Task ReceiveWIPCommand_PrExists_MergeCommandIsRemoved() 69 | { 70 | var owner = Guid.NewGuid().ToString(); 71 | var repo = Guid.NewGuid().ToString(); 72 | 73 | var payloadString = await File.ReadAllTextAsync("../../../DummyEvents/IssueComment.json"); 74 | var payload = JsonConvert.DeserializeObject(payloadString); 75 | 76 | // Issue cancel comment 77 | payload["repository"]["name"] = repo; 78 | payload["repository"]["owner"]["login"] = owner; 79 | payload["issue"]["number"] = PR_ID; 80 | payload["comment"]["body"] = "miro wip"; 81 | 82 | // Insert PR 83 | await mergeRequestsCollection.Insert(owner, repo, PR_ID); 84 | 85 | // Update with MergeCommand 86 | await mergeRequestsCollection.UpdateMergeRequest(owner, repo, PR_ID, true, DateTime.UtcNow); 87 | 88 | // Mock Comments 89 | var createCommentCallId = await MockCommentGithubCallHelper.MockCommentGithubCallWIP(owner, repo, PR_ID); 90 | 91 | // Action 92 | await SendWebhookRequest("issue_comment", JsonConvert.SerializeObject(payload)); 93 | 94 | // Assert 95 | var mergeRequest = await mergeRequestsCollection.Collection.Find(d => d["Owner"] == owner && d["Repo"] == repo && d["PrId"] == PR_ID).FirstOrDefaultAsync(); 96 | Assert.NotNull(mergeRequest); 97 | 98 | Assert.False((bool) mergeRequest["ReceivedMergeCommand"]); 99 | Assert.True(mergeRequest["ReceivedMergeCommandTimestamp"] == DateTime.MaxValue); 100 | 101 | var createCommentCall = await GetCall(createCommentCallId); 102 | Assert.True(createCommentCall.HasBeenMade, "a cancel comment should have been posted to the pr"); 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /Miro/Services/Github/EventHandlers/StatusEventHandler.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using Miro.Models.Checks; 7 | using Miro.Models.Github.IncomingEvents; 8 | using Miro.Models.Github.Responses; 9 | using Miro.Models.Merge; 10 | using Miro.Services.Checks; 11 | using Miro.Services.Logger; 12 | using Miro.Services.Merge; 13 | using Serilog; 14 | 15 | namespace Miro.Services.Github.EventHandlers 16 | { 17 | public class StatusEventHandler : IWebhookEventHandler 18 | { 19 | private readonly MergeRequestsRepository mergeRequestsRepository; 20 | private readonly MergeOperations mergeOperations; 21 | private readonly ChecksManager checksManager; 22 | private readonly CommentCreator commentCreator; 23 | private readonly ILogger logger = Log.ForContext(); 24 | 25 | public StatusEventHandler(MergeRequestsRepository mergeRequestsRepository, 26 | MergeOperations mergeOperations, 27 | ChecksManager checksManager, 28 | CommentCreator commentCreator) 29 | { 30 | this.mergeRequestsRepository = mergeRequestsRepository; 31 | this.mergeOperations = mergeOperations; 32 | this.checksManager = checksManager; 33 | this.commentCreator = commentCreator; 34 | } 35 | 36 | public async Task Handle(StatusEvent payload) 37 | { 38 | var sha = payload.Sha; 39 | var extraLogData = new {payload.Sha, owner = payload.Repository.Owner.Login, repo = payload.Repository.Name}; 40 | logger.WithExtraData(extraLogData).Information($"Received status from branch, handling"); 41 | return await HandleStatus(sha, payload); 42 | } 43 | 44 | private async Task HandleStatus(string sha, StatusEvent payload) 45 | { 46 | 47 | 48 | var mergeRequest = await mergeRequestsRepository.GetBySha(payload.Repository.Owner.Login, payload.Repository.Name, sha); 49 | if (mergeRequest == null) 50 | { 51 | logger.WithExtraData(new {payload.Sha, owner = payload.Repository.Owner.Login, repo = payload.Repository.Name}).Information("Received status from unknown Pull Request, ignoring"); 52 | return new WebhookResponse(false, "Received status from unknown Pull Request, ignoring"); 53 | } 54 | var owner = mergeRequest.Owner; 55 | var repo = mergeRequest.Repo; 56 | var prId = mergeRequest.PrId; 57 | var testName = payload.Context; 58 | var testState = payload.State; 59 | var targetUrl = payload.TargetUrl; 60 | 61 | if (!await checksManager.IsRequiredCheck(owner, repo, testName)) 62 | { 63 | logger.WithMergeRequestData(mergeRequest).WithExtraData(new {testName, testState, targetUrl}).Information("Received a non-required status from Pull Request, ignoring"); 64 | return new WebhookResponse(false, "Received a non-required status from Pull Request, ignoring"); 65 | } 66 | 67 | if (isStaleStatusEvent(mergeRequest, sha)) 68 | { 69 | logger.WithMergeRequestData(mergeRequest).WithExtraData(new {testName, testState, staleSha = sha}).Information("Received stale status from Pull Request, ignoring"); 70 | return new WebhookResponse(false, "Received stale status from Pull Request, ignoring"); 71 | } 72 | logger.WithMergeRequestData(mergeRequest).WithExtraData(new {testName, testState, staleSha = sha}).Information($"Received status from Pull Request, updating DB"); 73 | var updatedMergeRequest = await mergeRequestsRepository.UpdateCheckStatus(owner, repo, prId, testName, testState, targetUrl); 74 | if (testState == "success") 75 | { 76 | var checks = updatedMergeRequest.Checks?.Select(x => $"{x.Name}_{x.Status}"); 77 | logger.WithMergeRequestData(mergeRequest).WithExtraData(new {testName, testState, staleSha = sha, checks = String.Join(",", checks)}).Information($"Received status from Pull Request, updated DB"); 78 | var merged = await mergeOperations.TryToMerge(updatedMergeRequest); 79 | return new WebhookResponse(true, $"Received success status from Pull Request, did branch merge: {merged}"); 80 | } 81 | return new WebhookResponse(true, $"Received {testState} status from Pull Request, handled without trying to merge"); 82 | } 83 | 84 | private bool isStaleStatusEvent(MergeRequest mergeRequest, string sha) => mergeRequest.Sha != null && mergeRequest.Sha != sha; 85 | } 86 | } -------------------------------------------------------------------------------- /Miro.Tests/Helpers/GithubApiMock.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Net.Http; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using Newtonsoft.Json; 7 | 8 | namespace Miro.Tests.Helpers 9 | { 10 | public class GithubApiMock 11 | { 12 | private static string GithubUrl = Environment.GetEnvironmentVariable("GITHUB_API_URL") ?? "http://localhost:3000"; 13 | 14 | public static async Task MockGithubCall(string method, string url, string requestBody, string mockResponse, bool isJson = true) 15 | { 16 | var simpleFakeServerRequest = new HttpRequestMessage(HttpMethod.Post, $"{GithubUrl}/fake_server_admin/calls"); 17 | var mockCommentCall = new 18 | { 19 | method = method, 20 | url = url, 21 | body = requestBody, 22 | response = mockResponse, 23 | isJson = isJson 24 | }; 25 | 26 | simpleFakeServerRequest.Content = new StringContent(JsonConvert.SerializeObject(mockCommentCall), Encoding.UTF8, "application/json"); 27 | 28 | var response = await new HttpClient().SendAsync(simpleFakeServerRequest); 29 | var content = await response.Content.ReadAsStringAsync(); 30 | var json = JsonConvert.DeserializeObject(content); 31 | return json.CallId; 32 | } 33 | 34 | public static Task MockGithubCall(string method, string url, string mockResponse, bool isJson = true) => MockGithubCall(method, url, null, mockResponse, isJson); 35 | public static async Task MockGithubCall(string method, string url, string requestBody, int statusCode) 36 | { 37 | var simpleFakeServerRequest = new HttpRequestMessage(HttpMethod.Post, $"{GithubUrl}/fake_server_admin/calls"); 38 | var mockCommentCall = new 39 | { 40 | body = requestBody, 41 | method = method, 42 | url = url, 43 | statusCode = statusCode 44 | }; 45 | 46 | simpleFakeServerRequest.Content = new StringContent(JsonConvert.SerializeObject(mockCommentCall), Encoding.UTF8, "application/json"); 47 | 48 | var response = await new HttpClient().SendAsync(simpleFakeServerRequest); 49 | var content = await response.Content.ReadAsStringAsync(); 50 | var json = JsonConvert.DeserializeObject(content); 51 | return json.CallId; 52 | } 53 | 54 | public static async Task> GetCall(string callId) 55 | { 56 | var httpClient = new HttpClient(); 57 | var url = $"{GithubUrl}/fake_server_admin/calls?callId={callId}"; 58 | 59 | var getMadeCallRequest = new HttpRequestMessage(HttpMethod.Get, url); 60 | var madeCallResponse = await httpClient.SendAsync(getMadeCallRequest); 61 | var madeCallResult = await madeCallResponse.Content.ReadAsStringAsync(); 62 | var jsonAssertResult = JsonConvert.DeserializeObject>(madeCallResult); 63 | 64 | return jsonAssertResult; 65 | } 66 | 67 | public static async Task>> GetCall(string callId) 68 | { 69 | var httpClient = new HttpClient(); 70 | var url = $"{GithubUrl}/fake_server_admin/calls?callId={callId}"; 71 | 72 | var getMadeCallRequest = new HttpRequestMessage(HttpMethod.Get, url); 73 | var madeCallResponse = await httpClient.SendAsync(getMadeCallRequest); 74 | var madeCallResult = await madeCallResponse.Content.ReadAsStringAsync(); 75 | var jsonAssertResult = JsonConvert.DeserializeObject>>(madeCallResult); 76 | 77 | return jsonAssertResult; 78 | } 79 | 80 | public static async Task ResetGithubMock() 81 | { 82 | var httpClient = new HttpClient(); 83 | var url = $"{GithubUrl}/fake_server_admin/calls"; 84 | 85 | var getMadeCallRequest = new HttpRequestMessage(HttpMethod.Delete, url); 86 | await httpClient.SendAsync(getMadeCallRequest); 87 | } 88 | } 89 | 90 | public class MockedCall 91 | { 92 | public string CallId { get; set; } 93 | } 94 | 95 | public class CallCheck 96 | { 97 | public bool HasBeenMade { get; set; } 98 | public CallDetails Details { get; set; } 99 | } 100 | 101 | public class CallDetails 102 | { 103 | public string Method { get; set; } 104 | public string Path { get; set; } 105 | public Dictionary Headers { get; set; } 106 | public T Body { get; set; } 107 | } 108 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Miro](https://img.shields.io/badge/Merge--Bot-Miro-green.svg)](https://github.com/Soluto/Miro) 2 | 3 | 4 | # MIRO - Merge it robot! 5 | Miro was designed to help merge code from Github Pull Requests. 6 | 7 | 8 | 9 | - [MIRO - Merge it robot!](#miro---merge-it-robot) 10 | - [Deploying](#deploying) 11 | - [How does it work](#how-does-it-work) 12 | - [Miro Merge Command](#miro-merge-command) 13 | - [Miro Cancel Command](#miro-cancel-command) 14 | - [Miro Info Command](#miro-info-command) 15 | - [Miro WIP Command](#miro-wip-command) 16 | - [Additional Features](#additional-features) 17 | - [Optional repository Config File - .miro.yml](#optional-repository-config-file---miroyml) 18 | - [mergePolicy (whitelist | blacklist | whitelist-strict)](#mergepolicy-whitelist--blacklist--whitelist-strict) 19 | - [blacklist](#blacklist) 20 | - [whitelist](#whitelist) 21 | - [whitelist-strict](#whitelist-strict) 22 | - [Contributing](#contributing) 23 | 24 | 25 | 26 | 27 | ## Deploying 28 | Miro is a completely Open-Source project written in .NET Core. 29 | To use Miro in your org/team/projects all you need to do is pull + run the [miro docker image](https://cloud.docker.com/repository/docker/oresoluto/miro). 30 | 31 | Learn more about deploying Miro in the [deploying readme](./docs/DEPLOYING.md) 32 | 33 | 34 | ## How does it work 35 | **Important - for Miro to work, your master branch must be a protected branch** 36 | 37 | Miro listens to events in Github, and acts accordingly. 38 | The events Miro listens to can be divided to 3 major events: 39 | - Pull Request opened/closed 40 | - Merge command 41 | - Status checks 42 | 43 | Once all the status checks have passed, and the Miro merge command was given (in any order), Miro will try and merge the branch. 44 | Why only try? Because the branch might not be updated. In that case, Miro will update your branch automatically and wait for the CI to run again. 45 | 46 | ### Miro Merge Command 47 | In your PR, you need to comment `miro merge` only ONCE. 48 | This will cause Miro to wake up, and try and merge your Pull Request. 49 | For a PR to be mergeable, it needs to pass all status checks. 50 | 51 | The beauty of Miro, is that you can type the miro command anytime, even before the checks have passed, and Miro will continue to try 52 | and merge your PR until all status checks have passed. 53 | 54 | Another cool feature, is that Miro will try and update your branch if its not up to date with master. 55 | Meaning **you do not need to press 'Update Branch'!**. 56 | Once the branch is updated, the CI will re-run and Miro will try and merge the PR again next time. 57 | 58 | ### Miro Cancel Command 59 | `miro cancel` Will stop Miro from trying to Merge your Pull Request. If no `miro-merge` command was given before, this command does nothing. It does not remove the PR from the Database 60 | 61 | ### Miro Info Command 62 | `miro info` Will show you the status of your PullRequest according to Miro. For example what checks are missing. 63 | Once all checks have passed, if you ask miro for info he should say "Pr is ready for merging" 64 | 65 | ### Miro WIP Command 66 | `miro wip` Only in `blacklist` merge policy mode. In other merge policies, this acts like `miro cancel`. For more info on [merge policy](#mergepolicy-whitelist--blacklist--whitelist-strict) 67 | 68 | ## Additional Features 69 | 70 | ### Optional repository Config File - .miro.yml 71 | On every push to `master` Miro will grab the latest `.miro.yml` file if it exists, if not, it will use the defaults. 72 | It can look like this: 73 | 74 | ```yml 75 | 76 | updateBranchStrategy: all|oldest|none # When a PR is merged, how do we update the next - default: oldest 77 | 78 | mergePolicy: whitelist|blacklist|whitelist-strict # Merging strategy - default: whitelist 79 | 80 | defaultBranch: master # describes the branch that Miro listens to for updating, merging and all operations - default: master 81 | 82 | quiet: false # if set to true, Miro will write less commits on your repo, notifiying you only when merging fails 83 | ``` 84 | 85 | #### mergePolicy (whitelist | blacklist | whitelist-strict) 86 | 87 | ##### blacklist 88 | All PRs that are ready are merged automatically, no need to type `miro-merge`. PRs that want to be blacklisted must comment `miro wip` 89 | 90 | ##### whitelist 91 | Only PRs that typed `miro-merge` will be merged 92 | 93 | ##### whitelist-strict 94 | To enable this feature, you must, **after declaring** it in the `.miro.yml` file, do the following: 95 | - In Github, Make the Miro check a required status check like in the example below: 96 | 97 | ![miro merge check](./docs/only_miro_can_merge_img.png) 98 | 99 | ## Contributing 100 | Read the [contribution readme](./docs/CONTRIBUTING.md) 101 | -------------------------------------------------------------------------------- /Miro.Tests/ReviewEventProcessingTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Net; 5 | using System.Net.Http; 6 | using System.Security.Cryptography; 7 | using System.Text; 8 | using System.Threading.Tasks; 9 | using Miro.Tests.Helpers; 10 | using MongoDB.Bson; 11 | using MongoDB.Driver; 12 | using Newtonsoft.Json; 13 | using Xunit; 14 | using static Miro.Tests.Helpers.WebhookRequestSender; 15 | using static Miro.Tests.Helpers.GithubUrlHelpers; 16 | using static Miro.Tests.Helpers.GithubApiMock; 17 | 18 | namespace Miro.Tests 19 | { 20 | public class ReviewEventProcessingTests 21 | { 22 | const int PR_ID = 8; 23 | 24 | private MergeRequestsCollection mergeRequestsCollection; 25 | private readonly CheckListsCollection checkListsCollection; 26 | 27 | public ReviewEventProcessingTests() 28 | { 29 | this.mergeRequestsCollection = new MergeRequestsCollection(); 30 | this.checkListsCollection = new CheckListsCollection(); 31 | } 32 | 33 | [Fact] 34 | public async Task ReceievePullRequestReviewChangesRequestedEvent_AllTestsPassed_DoNothing() 35 | { 36 | var owner = Guid.NewGuid().ToString(); 37 | var repo = Guid.NewGuid().ToString(); 38 | 39 | var payloadString = await File.ReadAllTextAsync("../../../DummyEvents/ReviewPullRequest.json"); 40 | var payload = JsonConvert.DeserializeObject(payloadString); 41 | 42 | payload["repository"]["name"] = repo; 43 | payload["repository"]["owner"]["login"] = owner; 44 | payload["pull_request"]["number"] = PR_ID; 45 | payload["review"]["state"] = "CHANGES_REQUESTED"; 46 | 47 | // Mock github 48 | var mergePrCallId = await MockMergeGithubCallHelper.MockMergeCall(owner, repo, PR_ID); 49 | 50 | // insert to DB with all checks passed 51 | await checkListsCollection.InsertWithDefaultChecks(owner, repo); 52 | await mergeRequestsCollection.InsertWithTestChecksSuccessAndMergeCommand(owner, repo, PR_ID); 53 | await MockReviewGithubCallHelper.MockAllReviewsPassedResponses(owner, repo, PR_ID); 54 | 55 | // ACTION 56 | await SendWebhookRequest("pull_request_review", JsonConvert.SerializeObject(payload)); 57 | 58 | // ASSERT 59 | var mergePrCall = await GetCall(mergePrCallId); 60 | Assert.False(mergePrCall.HasBeenMade, "pr should not have been merged"); 61 | 62 | var mergeRequest = await mergeRequestsCollection.Collection.Find(d => d["Owner"] == owner && d["Repo"] == repo && d["PrId"] == PR_ID).FirstAsync(); 63 | Assert.NotNull(mergeRequest); 64 | } 65 | 66 | [Fact] 67 | public async Task ReceievePullRequestReviewApprovedEvent_AllTestsPassed_MergePr() 68 | { 69 | var owner = Guid.NewGuid().ToString(); 70 | var repo = Guid.NewGuid().ToString(); 71 | var sha = Guid.NewGuid().ToString(); 72 | 73 | var payloadString = await File.ReadAllTextAsync("../../../DummyEvents/ReviewPullRequest.json"); 74 | var payload = JsonConvert.DeserializeObject(payloadString); 75 | 76 | payload["repository"]["name"] = repo; 77 | payload["repository"]["owner"]["login"] = owner; 78 | payload["pull_request"]["number"] = PR_ID; 79 | 80 | // Mock github 81 | var mergeCommentCallId = await MockCommentGithubCallHelper.MockCommentGithubCallMerging(owner, repo, PR_ID); 82 | var mergePrCallId = await MockMergeGithubCallHelper.MockMergeCall(owner, repo, PR_ID); 83 | // var miroMergeCheckCallId = await MockGithubCall("post", StatusCheckUrlFor(owner, repo, sha), "{}", false); 84 | await MockReviewGithubCallHelper.MockAllReviewsPassedResponses(owner, repo, PR_ID); 85 | 86 | // insert to DB with all checks passed 87 | await checkListsCollection.InsertWithDefaultChecks(owner, repo); 88 | await mergeRequestsCollection.InsertWithTestChecksSuccessAndMergeCommand(owner, repo, PR_ID, null, sha); 89 | 90 | // ACTION 91 | await SendWebhookRequest("pull_request_review", JsonConvert.SerializeObject(payload)); 92 | 93 | // ASSERT 94 | var mergeCommentCall = await GetCall(mergeCommentCallId); 95 | var mergePrCall = await GetCall(mergePrCallId); 96 | // var miroMergeCheckCall = await GetCall(miroMergeCheckCallId); 97 | Assert.True(mergeCommentCall.HasBeenMade, "a merging comment should have been posted to the pr"); 98 | // Assert.True(miroMergeCheckCall.HasBeenMade, "a call to delete miro merge check should have been called"); 99 | Assert.True(mergePrCall.HasBeenMade, "pr should have been merged"); 100 | 101 | var mergeRequest = await mergeRequestsCollection.Collection.Find(d => d["Owner"] == owner && d["Repo"] == repo && d["PrId"] == PR_ID).FirstAsync(); 102 | Assert.NotNull(mergeRequest); 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /Miro/Services/Merge/MergeabilityValidator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using Miro.Models.Merge; 7 | using Miro.Models.Validation; 8 | using Miro.Services.Checks; 9 | using Miro.Services.Comments; 10 | using Miro.Services.Github; 11 | using Miro.Services.Logger; 12 | using Serilog; 13 | 14 | namespace Miro.Services.Merge 15 | { 16 | public class MergeabilityValidator 17 | { 18 | private readonly ReviewsRetriever reviewsRetriever; 19 | private readonly ILogger logger = Log.ForContext(); 20 | private readonly ChecksManager checksManager; 21 | 22 | public MergeabilityValidator(ReviewsRetriever reviewsRetriever, ChecksManager checksManager) 23 | { 24 | this.reviewsRetriever = reviewsRetriever; 25 | this.checksManager = checksManager; 26 | } 27 | 28 | public async Task> ValidateMergeability(MergeRequest mergeRequest, bool ignoreMiroMergeCheck = false) 29 | { 30 | var owner = mergeRequest.Owner; 31 | var repo = mergeRequest.Repo; 32 | var prId = mergeRequest.PrId; 33 | var errors = new List(); 34 | 35 | var pendingReviewsError = await ValidateNoPendingReviews(owner, repo, prId); 36 | if (pendingReviewsError != null) 37 | { 38 | errors.Add(pendingReviewsError); 39 | } 40 | 41 | var changesRequestedError = await ValidateNoChangesRequested(mergeRequest); 42 | if (changesRequestedError != null) 43 | { 44 | errors.Add(changesRequestedError); 45 | } 46 | 47 | var missingChecksError = await ValidateNoChecksMissing(mergeRequest, ignoreMiroMergeCheck); 48 | if (missingChecksError != null) 49 | { 50 | errors.Add(missingChecksError); 51 | } 52 | return errors; 53 | } 54 | 55 | private async Task ValidateNoPendingReviews(string owner, string repo, int prId) 56 | { 57 | logger.WithExtraData(new { owner, repo, prId }).Information("Checking if PR has pending reviews"); 58 | var requestedReviewers = await reviewsRetriever.GetRequestedReviewers(owner, repo, prId); 59 | 60 | if (requestedReviewers.Teams.Any() || requestedReviewers.Users.Any()) 61 | { 62 | logger.WithExtraData(new { owner, repo, prId }).Information("PR has pending reviews"); 63 | var stringBuilder = new StringBuilder(); 64 | stringBuilder.AppendLine("Still waiting for a review from: "); 65 | requestedReviewers.Teams.ForEach(t => stringBuilder.AppendLine(t.Name)); 66 | requestedReviewers.Users.ForEach(u => stringBuilder.AppendLine(u.Login)); 67 | 68 | return new ValidationError { Error = stringBuilder.ToString() }; 69 | } 70 | 71 | return null; 72 | } 73 | 74 | private async Task ValidateNoChecksMissing(MergeRequest mergeRequest, bool ignoreMiroMergeCheck) 75 | { 76 | logger.WithMergeRequestData(mergeRequest).Information("Checking if PR has missing checks"); 77 | 78 | var missingChecks = await checksManager.GetMissingChecks(mergeRequest.Owner, mergeRequest.Repo, mergeRequest.Checks); 79 | if (missingChecks.Any()) 80 | { 81 | if (ignoreMiroMergeCheck && missingChecks.Count == 1 && missingChecks[0] == CommentsConsts.MiroMergeCheckName) 82 | { 83 | logger.WithMergeRequestData(mergeRequest).Information($"Only missing check is Miro merge check, skipping"); 84 | return null; 85 | } 86 | logger.WithMergeRequestData(mergeRequest).Information($"PR has missing checks"); 87 | var stringBuilder = new StringBuilder(); 88 | stringBuilder.AppendLine("Pending status checks: "); 89 | missingChecks.ForEach(c => stringBuilder.AppendLine(c)); 90 | 91 | return new ValidationError { Error = stringBuilder.ToString() }; 92 | } 93 | return null; 94 | } 95 | 96 | private async Task ValidateNoChangesRequested(MergeRequest mergeRequest) 97 | { 98 | var owner = mergeRequest.Owner; 99 | var repo = mergeRequest.Repo; 100 | var prId = mergeRequest.PrId; 101 | 102 | logger.WithMergeRequestData(mergeRequest).Information("Checking if PR has changes requested"); 103 | var reviews = await reviewsRetriever.GetReviews(owner, repo, prId); 104 | var latestReviewPerUser = reviews.GroupBy(r => r.User.Id) 105 | .Select(g => g.OrderByDescending(r => r.SubmittedAt) 106 | .First()); 107 | 108 | var changesRequestedReviews = latestReviewPerUser.Where(r => r.State == "CHANGES_REQUESTED"); 109 | if (changesRequestedReviews.Any()) 110 | { 111 | logger.WithMergeRequestData(mergeRequest).Information("PR has requested changes"); 112 | var stringBuilder = new StringBuilder(); 113 | stringBuilder.AppendLine("Changes requested by: "); 114 | changesRequestedReviews.ToList().ForEach(r => stringBuilder.AppendLine(r.User.Login)); 115 | 116 | return new ValidationError { Error = stringBuilder.ToString() }; 117 | } 118 | 119 | return null; 120 | } 121 | } 122 | } -------------------------------------------------------------------------------- /Miro/Services/Merge/MergeOperations.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using Miro.Models.Merge; 5 | using Miro.Services.Checks; 6 | using Miro.Services.Comments; 7 | using Miro.Services.Github; 8 | using Miro.Services.Logger; 9 | using Miro.Services.MiroConfig; 10 | using Serilog; 11 | 12 | namespace Miro.Services.Merge 13 | { 14 | public class MergeOperations 15 | { 16 | private readonly PrMerger prMerger; 17 | private readonly PrUpdater prUpdater; 18 | private readonly CommentCreator commentCreator; 19 | private readonly MergeRequestsRepository mergeRequestRepository; 20 | private readonly MiroMergeCheck miroMergeCheck; 21 | private readonly MergeabilityValidator mergeabilityValidator; 22 | private readonly RepoConfigManager repoConfigManager; 23 | 24 | private readonly ILogger logger = Log.ForContext(); 25 | 26 | public MergeOperations( 27 | PrMerger prMerger, 28 | PrUpdater prUpdater, 29 | CommentCreator commentCreator, 30 | MergeRequestsRepository mergeRequestRepository, 31 | MiroMergeCheck miroMergeCheck, 32 | RepoConfigManager repoConfigManager, 33 | MergeabilityValidator mergeabilityValidator) 34 | { 35 | this.prMerger = prMerger; 36 | this.prUpdater = prUpdater; 37 | this.commentCreator = commentCreator; 38 | this.mergeRequestRepository = mergeRequestRepository; 39 | this.miroMergeCheck = miroMergeCheck; 40 | this.mergeabilityValidator = mergeabilityValidator; 41 | this.repoConfigManager = repoConfigManager; 42 | } 43 | 44 | public async Task TryToMerge(MergeRequest mergeRequest) 45 | { 46 | if (mergeRequest == null) 47 | { 48 | logger.Warning($"Received TryToMerge command from a null mergeRequest"); 49 | throw new Exception("Can not merge, PR is not defined"); 50 | } 51 | 52 | if (!mergeRequest.ReceivedMergeCommand) 53 | { 54 | logger.WithMergeRequestData(mergeRequest).Information($"PR can't be merged, missing merge command"); 55 | return false; 56 | } 57 | 58 | var mergeabilityValidationErrors = await mergeabilityValidator.ValidateMergeability(mergeRequest); 59 | 60 | if (mergeabilityValidationErrors.Any()) 61 | { 62 | logger.WithMergeRequestData(mergeRequest).Information($"PR can't be merged, found mergeability validation errors"); 63 | return false; 64 | } 65 | 66 | return await MergeOrUpdateBranch(mergeRequest); 67 | } 68 | 69 | 70 | private async Task TryToUpdateBranch(string owner, string repo, int prId, string branch) 71 | { 72 | var extraLogData = new { owner, repo, branch }; 73 | try 74 | { 75 | await prUpdater.UpdateBranch(owner, repo, branch); 76 | } 77 | catch (Exception er) 78 | { 79 | logger.WithExtraData(extraLogData).Error(er, $"Could not update branch for PR due to exception from the github api"); 80 | await commentCreator.CreateComment(owner, repo, prId, CommentsConsts.CantUpdateBranchHeader, er.Message, CommentsConsts.CantUpdateBranchBody); 81 | } 82 | } 83 | 84 | private async Task MergeOrUpdateBranch(MergeRequest mergeRequest) 85 | { 86 | var owner = mergeRequest.Owner; 87 | var repo = mergeRequest.Repo; 88 | var prId = mergeRequest.PrId; 89 | var branch = mergeRequest.Branch; 90 | var config = await repoConfigManager.GetConfig(owner, repo); 91 | var quiet = config.Quiet; 92 | 93 | logger.WithMergeRequestData(mergeRequest).Information($"A READY merge request for PR found, merging"); 94 | if (!quiet) { 95 | await commentCreator.CreateListedComment(owner, repo, prId, CommentsConsts.Merging, mergeRequest.Checks.Select(x => $":heavy_check_mark: {x.Name}").ToList()); 96 | } 97 | try 98 | { 99 | await prMerger.Merge(mergeRequest); 100 | await mergeRequestRepository.UpdateState(owner, repo, prId, "MERGED"); 101 | return true; 102 | } 103 | catch (PullRequestMismatchException prEx) 104 | { 105 | if (mergeRequest.IsFork) 106 | { 107 | await commentCreator.CreateComment(owner, repo, prId, CommentsConsts.PullRequestCanNotBeMerged, CommentsConsts.UpdatingAForkNotAllowed); 108 | return false; 109 | } 110 | logger.WithMergeRequestData(mergeRequest).Information(prEx, $"Could not merge PR due to PullRequestMismatchException exception"); 111 | await commentCreator.CreateComment(owner, repo, prId, CommentsConsts.PullRequestCanNotBeMerged, prEx.Message, CommentsConsts.TryToUpdateWithDefaultBranch); 112 | await TryToUpdateBranch(owner, repo, prId, mergeRequest.Branch); 113 | return false; 114 | 115 | } 116 | catch (Exception e) 117 | { 118 | logger.WithMergeRequestData(mergeRequest).Error(e, $"Could not merge PR due to unknown exception from github API"); 119 | await commentCreator.CreateComment(owner, repo, prId, CommentsConsts.PullRequestCanNotBeMerged, e.Message); 120 | return false; 121 | } 122 | } 123 | 124 | } 125 | } -------------------------------------------------------------------------------- /Miro.Tests/obj/Miro.Tests.csproj.nuget.dgspec.json: -------------------------------------------------------------------------------- 1 | { 2 | "format": 1, 3 | "restore": { 4 | "/Users/ore/dev/repos/Miro/Miro.Tests/Miro.Tests.csproj": {} 5 | }, 6 | "projects": { 7 | "/Users/ore/dev/repos/Miro/Miro.Tests/Miro.Tests.csproj": { 8 | "version": "1.0.0", 9 | "restore": { 10 | "projectUniqueName": "/Users/ore/dev/repos/Miro/Miro.Tests/Miro.Tests.csproj", 11 | "projectName": "Miro.Tests", 12 | "projectPath": "/Users/ore/dev/repos/Miro/Miro.Tests/Miro.Tests.csproj", 13 | "packagesPath": "/Users/ore/.nuget/packages/", 14 | "outputPath": "/Users/ore/dev/repos/Miro/Miro.Tests/obj/", 15 | "projectStyle": "PackageReference", 16 | "configFilePaths": [ 17 | "/Users/ore/.nuget/NuGet/NuGet.Config" 18 | ], 19 | "originalTargetFrameworks": [ 20 | "netcoreapp2.1" 21 | ], 22 | "sources": { 23 | "https://api.nuget.org/v3/index.json": {} 24 | }, 25 | "frameworks": { 26 | "netcoreapp2.1": { 27 | "targetAlias": "netcoreapp2.1", 28 | "projectReferences": { 29 | "/Users/ore/dev/repos/Miro/Miro/Miro.csproj": { 30 | "projectPath": "/Users/ore/dev/repos/Miro/Miro/Miro.csproj" 31 | } 32 | } 33 | } 34 | }, 35 | "warningProperties": { 36 | "warnAsError": [ 37 | "NU1605" 38 | ] 39 | } 40 | }, 41 | "frameworks": { 42 | "netcoreapp2.1": { 43 | "targetAlias": "netcoreapp2.1", 44 | "dependencies": { 45 | "Microsoft.NET.Test.Sdk": { 46 | "target": "Package", 47 | "version": "[15.7.0, )" 48 | }, 49 | "Microsoft.NETCore.App": { 50 | "suppressParent": "All", 51 | "target": "Package", 52 | "version": "[2.1.0, )", 53 | "autoReferenced": true 54 | }, 55 | "MongoDB.Driver": { 56 | "target": "Package", 57 | "version": "[2.7.0, )" 58 | }, 59 | "xunit": { 60 | "target": "Package", 61 | "version": "[2.3.1, )" 62 | }, 63 | "xunit.runner.visualstudio": { 64 | "target": "Package", 65 | "version": "[2.3.1, )" 66 | } 67 | }, 68 | "imports": [ 69 | "net461", 70 | "net462", 71 | "net47", 72 | "net471", 73 | "net472", 74 | "net48" 75 | ], 76 | "assetTargetFallback": true, 77 | "warn": true, 78 | "runtimeIdentifierGraphPath": "/usr/local/share/dotnet/sdk/5.0.100/RuntimeIdentifierGraph.json" 79 | } 80 | } 81 | }, 82 | "/Users/ore/dev/repos/Miro/Miro/Miro.csproj": { 83 | "version": "1.0.0", 84 | "restore": { 85 | "projectUniqueName": "/Users/ore/dev/repos/Miro/Miro/Miro.csproj", 86 | "projectName": "Miro", 87 | "projectPath": "/Users/ore/dev/repos/Miro/Miro/Miro.csproj", 88 | "packagesPath": "/Users/ore/.nuget/packages/", 89 | "outputPath": "/Users/ore/dev/repos/Miro/Miro/obj/", 90 | "projectStyle": "PackageReference", 91 | "configFilePaths": [ 92 | "/Users/ore/.nuget/NuGet/NuGet.Config" 93 | ], 94 | "originalTargetFrameworks": [ 95 | "netcoreapp2.1" 96 | ], 97 | "sources": { 98 | "https://api.nuget.org/v3/index.json": {} 99 | }, 100 | "frameworks": { 101 | "netcoreapp2.1": { 102 | "targetAlias": "netcoreapp2.1", 103 | "projectReferences": {} 104 | } 105 | }, 106 | "warningProperties": { 107 | "warnAsError": [ 108 | "NU1605" 109 | ] 110 | } 111 | }, 112 | "frameworks": { 113 | "netcoreapp2.1": { 114 | "targetAlias": "netcoreapp2.1", 115 | "dependencies": { 116 | "GitHubJwt": { 117 | "target": "Package", 118 | "version": "[0.0.3, )" 119 | }, 120 | "Microsoft.AspNetCore.App": { 121 | "suppressParent": "All", 122 | "target": "Package", 123 | "version": "[2.1.1, )", 124 | "autoReferenced": true 125 | }, 126 | "Microsoft.AspNetCore.WebHooks.Receivers.GitHub": { 127 | "target": "Package", 128 | "version": "[1.0.0-preview2-final, )" 129 | }, 130 | "Microsoft.NETCore.App": { 131 | "suppressParent": "All", 132 | "target": "Package", 133 | "version": "[2.1.0, )", 134 | "autoReferenced": true 135 | }, 136 | "MongoDB.Driver": { 137 | "target": "Package", 138 | "version": "[2.7.0, )" 139 | }, 140 | "Serilog": { 141 | "target": "Package", 142 | "version": "[2.7.1, )" 143 | }, 144 | "Serilog.Settings.Configuration": { 145 | "target": "Package", 146 | "version": "[2.6.1, )" 147 | }, 148 | "Serilog.Sinks.Console": { 149 | "target": "Package", 150 | "version": "[3.1.1, )" 151 | }, 152 | "Swashbuckle.AspNetCore": { 153 | "target": "Package", 154 | "version": "[4.0.1, )" 155 | }, 156 | "System.Reactive": { 157 | "target": "Package", 158 | "version": "[4.1.0, )" 159 | } 160 | }, 161 | "imports": [ 162 | "net461", 163 | "net462", 164 | "net47", 165 | "net471", 166 | "net472", 167 | "net48" 168 | ], 169 | "assetTargetFallback": true, 170 | "warn": true, 171 | "runtimeIdentifierGraphPath": "/usr/local/share/dotnet/sdk/5.0.100/RuntimeIdentifierGraph.json" 172 | } 173 | } 174 | } 175 | } 176 | } -------------------------------------------------------------------------------- /Miro/Startup.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Net.Http; 5 | using System.Reactive; 6 | using System.Reactive.Concurrency; 7 | using System.Reactive.Linq; 8 | using System.Threading.Tasks; 9 | using GitHubJwt; 10 | using Microsoft.AspNetCore.Builder; 11 | using Microsoft.AspNetCore.Hosting; 12 | using Microsoft.AspNetCore.HttpsPolicy; 13 | using Microsoft.AspNetCore.Mvc; 14 | using Microsoft.Extensions.Configuration; 15 | using Microsoft.Extensions.DependencyInjection; 16 | using Microsoft.Extensions.Logging; 17 | using Microsoft.Extensions.Options; 18 | using Miro.Models.Checks; 19 | using Miro.Models.Merge; 20 | using Miro.Models.MiroConfig; 21 | using Miro.Services.Auth; 22 | using Miro.Services.Checks; 23 | using Miro.Services.Github; 24 | using Miro.Services.Github.EventHandlers; 25 | using Miro.Services.Merge; 26 | using Miro.Services.MiroConfig; 27 | using Miro.Services.MiroStats; 28 | using MiroConfig; 29 | using MongoDB.Driver; 30 | using Newtonsoft.Json; 31 | using Serilog; 32 | using Serilog.Formatting.Json; 33 | using Swashbuckle.AspNetCore.Swagger; 34 | 35 | namespace Miro 36 | { 37 | public class Startup 38 | { 39 | public Startup(IConfiguration configuration) 40 | { 41 | Configuration = configuration; 42 | } 43 | 44 | public IConfiguration Configuration { get; } 45 | 46 | // This method gets called by the runtime. Use this method to add services to the container. 47 | public void ConfigureServices(IServiceCollection services) 48 | { 49 | 50 | Log.Logger = new LoggerConfiguration() 51 | .ReadFrom.Configuration(Configuration) 52 | .Enrich.FromLogContext() 53 | .WriteTo.Console(new JsonFormatter()) 54 | .CreateLogger(); 55 | 56 | services.AddMvc() 57 | .SetCompatibilityVersion(CompatibilityVersion.Version_2_1) 58 | .AddGitHubWebHooks(); 59 | 60 | services.AddMiro(); 61 | services.AddMongoDb(Configuration); 62 | 63 | services.AddTransient(); 64 | services.AddTransient(); 65 | services.AddTransient(); 66 | services.AddTransient(); 67 | services.AddTransient(); 68 | services.AddTransient(); 69 | services.AddTransient(); 70 | services.AddTransient(); 71 | services.AddSingleton(new InstallationTokenStore(Configuration)); 72 | 73 | services.AddSwaggerGen(c => 74 | { 75 | c.AddSecurityDefinition("Api-Key", new ApiKeyScheme { In = "header", Description = "Please enter the valid API Key", Name = "Authorization", Type = "apiKey" }); 76 | c.AddSecurityRequirement(new Dictionary> { 77 | { "Api-Key", Enumerable.Empty() }, 78 | }); 79 | c.SwaggerDoc("v1", new Info { Title = "Miro API", Version = "v1" }); 80 | }); 81 | } 82 | 83 | // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. 84 | public void Configure(IApplicationBuilder app, IHostingEnvironment env) 85 | { 86 | if (env.IsDevelopment()) 87 | { 88 | app.UseDeveloperExceptionPage(); 89 | } 90 | else 91 | { 92 | app.UseHsts(); 93 | app.ApplyApiKeyValidation(); 94 | } 95 | app.UseSwagger(); 96 | app.UseSwaggerUI(c => 97 | { 98 | c.SwaggerEndpoint("/swagger/v1/swagger.json", "Miro API V1"); 99 | }); 100 | 101 | app.UseMvc(); 102 | } 103 | } 104 | 105 | public static class IServiceCollectionExt 106 | { 107 | public static IServiceCollection AddMiro(this IServiceCollection services) 108 | { 109 | services.AddScoped(); 110 | services.AddScoped(); 111 | services.AddScoped(); 112 | services.AddScoped(); 113 | services.AddScoped(); 114 | services.AddScoped(); 115 | services.AddScoped(); 116 | services.AddScoped(); 117 | services.AddScoped(); 118 | services.AddScoped(); 119 | services.AddScoped(); 120 | services.AddScoped(); 121 | services.AddScoped(); 122 | services.AddScoped(); 123 | services.AddScoped(); 124 | 125 | return services; 126 | } 127 | 128 | public static IServiceCollection AddMongoDb(this IServiceCollection services, IConfiguration configuration) 129 | { 130 | services.AddSingleton(new MongoClient(configuration["MONGO_CONNECTION_STRING"])); 131 | services.AddScoped(p => p.GetService() 132 | .GetDatabase("miro-db") 133 | .GetCollection("merge-requests")); 134 | services.AddScoped(p => p.GetService() 135 | .GetDatabase("miro-db") 136 | .GetCollection("check-lists")); 137 | services.AddScoped(p => p.GetService() 138 | .GetDatabase("miro-db") 139 | .GetCollection("repo-config")); 140 | 141 | return services; 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /Miro/Services/Github/EventHandlers/PushEventHandler.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using Miro.Models.Github.IncomingEvents; 7 | using Miro.Models.Github.Responses; 8 | using Miro.Models.Merge; 9 | using Miro.Models.MiroConfig; 10 | using Miro.Services.Checks; 11 | using Miro.Services.Logger; 12 | using Miro.Services.Merge; 13 | using Miro.Services.MiroConfig; 14 | using Serilog; 15 | 16 | namespace Miro.Services.Github.EventHandlers 17 | { 18 | public class PushEventHandler : IWebhookEventHandler 19 | { 20 | private readonly ILogger logger = Log.ForContext(); 21 | 22 | private readonly MergeRequestsRepository mergeRequestRepository; 23 | private readonly MiroMergeCheck miroMergeCheck; 24 | private readonly RepoConfigManager repoConfigManager; 25 | private readonly ChecksManager checksManager; 26 | private readonly PrUpdater prUpdater; 27 | 28 | public PushEventHandler( 29 | MergeRequestsRepository mergeRequestRepository, 30 | MiroMergeCheck miroMergeCheck, 31 | RepoConfigManager RepoConfigManager, 32 | ChecksManager checksManager, 33 | PrUpdater prUpdater) 34 | { 35 | this.mergeRequestRepository = mergeRequestRepository; 36 | this.miroMergeCheck = miroMergeCheck; 37 | repoConfigManager = RepoConfigManager; 38 | this.checksManager = checksManager; 39 | this.prUpdater = prUpdater; 40 | } 41 | 42 | public async Task Handle(PushEvent payload) 43 | { 44 | var owner = payload.Repository.Owner.Login; 45 | var repo = payload.Repository.Name; 46 | var sha = payload.After; 47 | var branch = payload.Ref; 48 | var config = await repoConfigManager.GetConfig(owner, repo); 49 | var defaultBranch = config.DefaultBranch; 50 | 51 | if (payload.Ref.Contains(defaultBranch, StringComparison.OrdinalIgnoreCase)) 52 | { 53 | var updatedConfig = await repoConfigManager.UpdateConfig(owner, repo); 54 | var updated = await UpdateNextPrByStrategy(payload, updatedConfig); 55 | await checksManager.UpdateChecks(owner, repo); 56 | return new WebhookResponse(true, $"Handled Push on default branch, was next branch updated: {updated}"); 57 | } 58 | 59 | logger.WithExtraData(new {owner = owner, repo = repo, branch = branch}).Information($"Received push event on branch, Handling.."); 60 | var extraLogData = new {owner, repo, branch, sha}; 61 | 62 | if (!branch.StartsWith("refs/heads/", StringComparison.OrdinalIgnoreCase)) 63 | { 64 | logger.WithExtraData(extraLogData).Information($"Push on branch, is not a head commit, ignoring..."); 65 | return new WebhookResponse(false, $"Received Push on branch which is not head, ignored"); 66 | } 67 | return await HandlePushOnPr(payload, config); 68 | } 69 | 70 | private async Task HandlePushOnPr(PushEvent payload, RepoConfig config) 71 | { 72 | var owner = payload.Repository.Owner.Login; 73 | var repo = payload.Repository.Name; 74 | var sha = payload.After; 75 | var branch = payload.Ref; 76 | var extraLogData = new {owner, repo, branch, sha}; 77 | 78 | var strippedBranchName = branch.Substring("refs/heads/".Length); 79 | var mergeRequest = await mergeRequestRepository.GetByBranchName(owner, repo, strippedBranchName); 80 | if (mergeRequest == null) 81 | { 82 | logger.WithExtraData(extraLogData).Information("Push on branch, does not exist in Miro DB, ignoring"); 83 | return new WebhookResponse(false, "Push on branch, does not exist in Miro DB, ignoring"); 84 | } 85 | 86 | logger.WithMergeRequestData(mergeRequest).Information($"Push on branch found in DB, Clearing status checks and updating sha"); 87 | 88 | var updatedMergeRequest = await mergeRequestRepository.UpdateShaAndClearStatusChecks(mergeRequest.Owner, mergeRequest.Repo, mergeRequest.PrId, sha); 89 | 90 | if (config.IsWhitelistStrict() && updatedMergeRequest.ReceivedMergeCommand) 91 | { 92 | logger.WithMergeRequestData(updatedMergeRequest).Information("Repository has a whitelist-strict merge policy, resolving miro check on PR"); 93 | await miroMergeCheck.ResolveMiroMergeCheck(updatedMergeRequest); 94 | } 95 | 96 | return new WebhookResponse(true, $"Push on branch is a known PR, updated sha to {sha} and cleared status checks"); 97 | } 98 | 99 | private async Task UpdateNextPrByStrategy(PushEvent payload, RepoConfig repoConfig) 100 | { 101 | var owner = payload.Repository.Owner.Login; 102 | var repo = payload.Repository.Name; 103 | logger.WithExtraData(new {owner, repo, strategy = repoConfig.UpdateBranchStrategy}).Information("Updating next PR by strategy"); 104 | 105 | List prsToUpdate = null; 106 | 107 | switch (repoConfig.UpdateBranchStrategy) 108 | { 109 | case "oldest": 110 | var singlePr = await mergeRequestRepository.GetOldestPr(owner, repo); 111 | if (singlePr != null) prsToUpdate = new List {singlePr}; 112 | break; 113 | 114 | case "all": 115 | var allPrs = await mergeRequestRepository.Get(owner, repo); 116 | if (allPrs != null && allPrs.Any()) prsToUpdate = allPrs.Where(x => x.ReceivedMergeCommand).ToList(); 117 | break; 118 | 119 | case "none": 120 | default: 121 | break; 122 | } 123 | if (prsToUpdate == null || !prsToUpdate.Any()) 124 | { 125 | logger.WithExtraData(new { owner, repo }).Warning($"Could not find next PRs to update based on after PR was merged"); 126 | return false; 127 | } 128 | var tasks = prsToUpdate.Select(pr => UpdateSinglePr(pr, repoConfig)); 129 | var completions = await Task.WhenAll(tasks); 130 | 131 | return completions.Any(x => x); 132 | } 133 | 134 | private async Task UpdateSinglePr(MergeRequest pullRequest, RepoConfig config) 135 | { 136 | var branch = pullRequest.Branch; 137 | var prId = pullRequest.PrId; 138 | logger.WithMergeRequestData(pullRequest).Information($"updating branch on next PullRequest"); 139 | 140 | try 141 | { 142 | await prUpdater.UpdateBranch(pullRequest.Owner, pullRequest.Repo, branch); 143 | return true; 144 | } 145 | catch (Exception e) 146 | { 147 | logger.WithMergeRequestData(pullRequest).Warning(e, "Unable to update branch on next PR"); 148 | return false; 149 | } 150 | } 151 | 152 | 153 | } 154 | } -------------------------------------------------------------------------------- /Miro.Tests/RepoConfigurationTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Net; 5 | using System.Net.Http; 6 | using System.Security.Cryptography; 7 | using System.Text; 8 | using System.Threading.Tasks; 9 | using Miro.Tests.Helpers; 10 | using MongoDB.Bson; 11 | using MongoDB.Driver; 12 | using Newtonsoft.Json; 13 | using Xunit; 14 | using static Miro.Tests.Helpers.WebhookRequestSender; 15 | using static Miro.Tests.Helpers.GithubUrlHelpers; 16 | using static Miro.Tests.Helpers.GithubApiMock; 17 | using System.Collections.Generic; 18 | 19 | namespace Miro.Tests 20 | { 21 | public class RepoConfigurationTests 22 | { 23 | const int PR_ID = 9; 24 | 25 | private readonly CheckListsCollection checkListsCollection; 26 | private MergeRequestsCollection mergeRequestsCollection; 27 | private RepoConfigurationCollection repoConfigurationCollection; 28 | 29 | public RepoConfigurationTests() 30 | { 31 | this.checkListsCollection = new CheckListsCollection(); 32 | this.mergeRequestsCollection = new MergeRequestsCollection(); 33 | this.repoConfigurationCollection = new RepoConfigurationCollection(); 34 | } 35 | 36 | 37 | [Fact] 38 | public async Task PushEventOnMaster_RepoConfigIsCreated() 39 | { 40 | var owner = Guid.NewGuid().ToString(); 41 | var repo = Guid.NewGuid().ToString(); 42 | 43 | // Issue Push event 44 | var payloadString = await File.ReadAllTextAsync("../../../DummyEvents/Push.json"); 45 | var payload = JsonConvert.DeserializeObject(payloadString); 46 | payload["repository"]["name"] = repo; 47 | payload["repository"]["owner"]["login"] = owner; 48 | 49 | // Mock Github call 50 | var getConfigFileCallId = await MockRepoConfigGithubCallHelper.MockRepoConfigGithubCall(owner, repo, "default.yml"); 51 | await MockRequiredChecksGithubCallHelper.MockRequiredChecks(owner, repo); 52 | 53 | // ACTION 54 | await SendWebhookRequest("push", JsonConvert.SerializeObject(payload)); 55 | 56 | // ASSERT 57 | var getConfigFileCall = await GetCall(getConfigFileCallId); 58 | Assert.True(getConfigFileCall.HasBeenMade, "getConfigFile call should have been made"); 59 | 60 | var repoConfig = await repoConfigurationCollection.Collection.Find(d => d["Owner"] == owner && d["Repo"] == repo).FirstAsync(); 61 | Assert.NotNull(repoConfig); 62 | Assert.Equal("all", repoConfig["UpdateBranchStrategy"]); 63 | Assert.Equal("whitelist-strict", repoConfig["MergePolicy"]); 64 | } 65 | 66 | [Fact] 67 | public async Task PushEventOnMaster_RepoConfigIsCreated_QuietAdded() 68 | { 69 | var owner = Guid.NewGuid().ToString(); 70 | var repo = Guid.NewGuid().ToString(); 71 | 72 | // Issue Push event 73 | var payloadString = await File.ReadAllTextAsync("../../../DummyEvents/Push.json"); 74 | var payload = JsonConvert.DeserializeObject(payloadString); 75 | payload["repository"]["name"] = repo; 76 | payload["repository"]["owner"]["login"] = owner; 77 | 78 | // Mock Github call 79 | var getConfigFileCallId = await MockRepoConfigGithubCallHelper.MockRepoConfigGithubCall(owner, repo, "quiet.yml"); 80 | await MockRequiredChecksGithubCallHelper.MockRequiredChecks(owner, repo); 81 | 82 | // ACTION 83 | await SendWebhookRequest("push", JsonConvert.SerializeObject(payload)); 84 | 85 | // ASSERT 86 | var getConfigFileCall = await GetCall(getConfigFileCallId); 87 | Assert.True(getConfigFileCall.HasBeenMade, "getConfigFile call should have been made"); 88 | 89 | var repoConfig = await repoConfigurationCollection.Collection.Find(d => d["Owner"] == owner && d["Repo"] == repo).FirstAsync(); 90 | Assert.NotNull(repoConfig); 91 | Assert.Equal("all", repoConfig["UpdateBranchStrategy"]); 92 | Assert.Equal("whitelist-strict", repoConfig["MergePolicy"]); 93 | Assert.True((bool) repoConfig["Quiet"]); 94 | } 95 | 96 | 97 | [Fact] 98 | public async Task PushEventOnMaster_RepoConfigIsCreated_InvalidYml_UseDefaults() 99 | { 100 | var owner = Guid.NewGuid().ToString(); 101 | var repo = Guid.NewGuid().ToString(); 102 | 103 | // Issue Push event 104 | var payloadString = await File.ReadAllTextAsync("../../../DummyEvents/Push.json"); 105 | var payload = JsonConvert.DeserializeObject(payloadString); 106 | payload["repository"]["name"] = repo; 107 | payload["repository"]["owner"]["login"] = owner; 108 | 109 | // Mock Github call 110 | var getConfigFileCallId = await MockRepoConfigGithubCallHelper.MockRepoConfigGithubCall(owner, repo, "invalid.yml"); 111 | await MockRequiredChecksGithubCallHelper.MockRequiredChecks(owner, repo); 112 | 113 | // ACTION 114 | await SendWebhookRequest("push", JsonConvert.SerializeObject(payload)); 115 | 116 | // ASSERT 117 | var getConfigFileCall = await GetCall(getConfigFileCallId); 118 | Assert.True(getConfigFileCall.HasBeenMade, "getConfigFile call should have been made"); 119 | 120 | var repoConfig = await repoConfigurationCollection.Collection.Find(d => d["Owner"] == owner && d["Repo"] == repo).FirstAsync(); 121 | Assert.NotNull(repoConfig); 122 | Assert.Equal("oldest", repoConfig["UpdateBranchStrategy"]); 123 | Assert.Equal("whitelist", repoConfig["MergePolicy"]); 124 | } 125 | 126 | 127 | [Fact] 128 | public async Task PushEventOnDefaultBranch_RepoConfigIsCreated() 129 | { 130 | var owner = Guid.NewGuid().ToString(); 131 | var repo = Guid.NewGuid().ToString(); 132 | 133 | // Issue Push event 134 | var payloadString = await File.ReadAllTextAsync("../../../DummyEvents/Push.json"); 135 | var payload = JsonConvert.DeserializeObject(payloadString); 136 | payload["ref"] = "other"; 137 | payload["repository"]["name"] = repo; 138 | payload["repository"]["owner"]["login"] = owner; 139 | 140 | // Mock Github call 141 | var getConfigFileCallId = await MockRepoConfigGithubCallHelper.MockRepoConfigGithubCall(owner, repo, "defaultBranchOther.yml"); 142 | await MockRequiredChecksGithubCallHelper.MockRequiredChecks(owner, repo, null, "other"); 143 | 144 | // ACTION 145 | await SendWebhookRequest("push", JsonConvert.SerializeObject(payload)); 146 | 147 | // ASSERT 148 | var getConfigFileCall = await GetCall(getConfigFileCallId); 149 | Assert.True(getConfigFileCall.HasBeenMade, "getConfigFile call should have been made"); 150 | 151 | var repoConfig = await repoConfigurationCollection.Collection.Find(d => d["Owner"] == owner && d["Repo"] == repo).FirstAsync(); 152 | Assert.NotNull(repoConfig); 153 | Assert.Equal("other", repoConfig["DefaultBranch"]); 154 | } 155 | 156 | [Fact] 157 | public async Task PushEventOnMaster_NoConfigFilePresent_UseDefaults() 158 | { 159 | var owner = Guid.NewGuid().ToString(); 160 | var repo = Guid.NewGuid().ToString(); 161 | 162 | // Issue Push event 163 | var payloadString = await File.ReadAllTextAsync("../../../DummyEvents/Push.json"); 164 | var payload = JsonConvert.DeserializeObject(payloadString); 165 | payload["repository"]["name"] = repo; 166 | payload["repository"]["owner"]["login"] = owner; 167 | 168 | // Mock Github call 169 | var getConfigFileCallId = await MockRepoConfigGithubCallHelper.MockFailingRepoConfigCall(owner, repo); 170 | await MockRequiredChecksGithubCallHelper.MockRequiredChecks(owner, repo); 171 | 172 | // ACTION 173 | await SendWebhookRequest("push", JsonConvert.SerializeObject(payload)); 174 | 175 | // ASSERT 176 | var getConfigFileCall = await GetCall(getConfigFileCallId); 177 | Assert.True(getConfigFileCall.HasBeenMade, "getConfigFile call should have been made"); 178 | 179 | var repoConfig = await repoConfigurationCollection.Collection.Find(d => d["Owner"] == owner && d["Repo"] == repo).FirstAsync(); 180 | Assert.NotNull(repoConfig); 181 | Assert.Equal("oldest", repoConfig["UpdateBranchStrategy"]); 182 | Assert.Equal("whitelist", repoConfig["MergePolicy"]); 183 | } 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /Miro/Services/Merge/MergeRequestsRepository.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Threading.Tasks; 3 | using Miro.Models.Checks; 4 | using Miro.Models.Merge; 5 | using MongoDB.Driver; 6 | using System.Collections.Generic; 7 | using System; 8 | using Miro.Models.Github.RequestPayloads; 9 | 10 | namespace Miro.Services.Merge 11 | { 12 | public class MergeRequestsRepository 13 | { 14 | private readonly IMongoCollection collection; 15 | 16 | public MergeRequestsRepository(IMongoCollection collection) 17 | { 18 | this.collection = collection; 19 | } 20 | 21 | public async Task> Get() => await collection.Find(_ => true).ToListAsync(); 22 | 23 | public async Task Get(string owner, string repo, int prId) 24 | { 25 | return await collection.Find(r => r.Owner == owner && r.Repo == repo && r.PrId == prId).FirstOrDefaultAsync(); 26 | } 27 | 28 | public async Task GetOldestPr(string owner, string repo) 29 | { 30 | var sortDefinition = Builders.Sort.Ascending("ReceivedMergeCommandTimestamp"); 31 | 32 | var allPrs = await collection.Find(r => r.Owner == owner && r.Repo == repo && r.ReceivedMergeCommand && r.State != "MERGED").Sort(sortDefinition).ToListAsync(); 33 | 34 | // First Attempt - First PR with no failing tests and merge command 35 | var allPrsWithoutFailingChecks = allPrs.FirstOrDefault(pr => pr.NoFailingChecks()); 36 | 37 | // Second Attempt - First PR merge command 38 | return allPrsWithoutFailingChecks ?? allPrs.FirstOrDefault(); 39 | } 40 | 41 | public async Task> Get(string owner, string repo) 42 | { 43 | return (await collection.FindAsync(r => r.Owner == owner && r.Repo == repo)).ToList(); 44 | } 45 | 46 | public async Task GetByBranchName(string owner, string repo, string branch) 47 | { 48 | return await collection.Find(r => r.Owner == owner && r.Repo == repo && r.Branch == branch).FirstOrDefaultAsync(); 49 | } 50 | 51 | public async Task GetBySha(string owner, string repo, string sha) 52 | { 53 | return await collection.Find(r => r.Owner == owner && r.Repo == repo && r.Sha == sha).FirstOrDefaultAsync(); 54 | } 55 | 56 | public async Task UpdateMergeCommand(string owner, string repo, int prId, bool mergeCommand, DateTime mergeCommandTime) 57 | { 58 | var options = new FindOneAndUpdateOptions 59 | { 60 | IsUpsert = false, 61 | ReturnDocument = ReturnDocument.After 62 | }; 63 | var update = Builders.Update 64 | .Set(r => r.ReceivedMergeCommand, mergeCommand) 65 | .Set(r => r.ReceivedMergeCommandTimestamp, mergeCommandTime); 66 | 67 | return await collection.FindOneAndUpdateAsync(r => r.Owner == owner && 68 | r.Repo == repo && 69 | r.PrId == prId, update, options); 70 | } 71 | 72 | 73 | public async Task UpdateCheckStatus(string owner, string repo, int prId, List checkList) 74 | { 75 | var updateTime = DateTime.UtcNow; 76 | 77 | var options = new FindOneAndUpdateOptions 78 | { 79 | IsUpsert = false, 80 | ReturnDocument = ReturnDocument.After 81 | }; 82 | var mergeRequest = await Get(owner, repo, prId); 83 | 84 | checkList.ForEach(requestCheck => 85 | { 86 | var check = mergeRequest.Checks.FirstOrDefault(mergeRequestCheck => mergeRequestCheck.Name == requestCheck.Context); 87 | if (check != null) 88 | { 89 | check.Status = requestCheck.State; 90 | check.UpdatedAt = updateTime; 91 | check.TargetUrl = requestCheck.TargetUrl; 92 | } 93 | else 94 | { 95 | mergeRequest.Checks.Add(new CheckStatus 96 | { 97 | Name = requestCheck.Context, 98 | Status = requestCheck.State, 99 | UpdatedAt = updateTime, 100 | TargetUrl = requestCheck.TargetUrl 101 | }); 102 | } 103 | }); 104 | 105 | var update = Builders.Update.Set(r => r.Checks, mergeRequest.Checks); 106 | 107 | return await collection.FindOneAndUpdateAsync(r => r.Owner == owner && 108 | r.Repo == repo && 109 | r.PrId == prId, update, options); 110 | } 111 | 112 | 113 | public async Task UpdateCheckStatus(string owner, string repo, int prId, string checkName, string checkStatus, string targetUrl) 114 | { 115 | MergeRequest response; 116 | var updateTime = DateTime.UtcNow; 117 | 118 | var options = new FindOneAndUpdateOptions 119 | { 120 | IsUpsert = false, 121 | ReturnDocument = ReturnDocument.After 122 | }; 123 | 124 | var filterForCheck = Builders.Filter.Where(r => r.Owner == owner && 125 | r.Repo == repo && 126 | r.PrId == prId && 127 | r.Checks.Any(i => i.Name == checkName)); 128 | var updateForExisting = Builders.Update 129 | .Set(x => x.Checks[-1].Status, checkStatus) 130 | .Set(x => x.Checks[-1].UpdatedAt, updateTime) 131 | .Set(x => x.Checks[-1].TargetUrl, targetUrl); 132 | 133 | response = await collection.FindOneAndUpdateAsync(filterForCheck, updateForExisting, options); 134 | 135 | if (response == null) 136 | { 137 | var filterForItem = Builders.Filter.Where(r => r.Owner == owner && 138 | r.Repo == repo && 139 | r.PrId == prId); 140 | var updateForNew = Builders.Update 141 | .Push(x => x.Checks, new CheckStatus 142 | { 143 | Name = checkName, 144 | Status = checkStatus, 145 | UpdatedAt = updateTime, 146 | TargetUrl = targetUrl 147 | }); 148 | 149 | response = await collection.FindOneAndUpdateAsync(filterForItem, updateForNew, options); 150 | } 151 | return response; 152 | } 153 | 154 | public async Task UpdateState(string owner, string repo, int prId, string state) 155 | { 156 | var options = new FindOneAndUpdateOptions 157 | { 158 | IsUpsert = false, 159 | ReturnDocument = ReturnDocument.After 160 | }; 161 | var update = Builders.Update.Set(r => r.State, state); 162 | 163 | await collection.FindOneAndUpdateAsync(r => r.Owner == owner && 164 | r.Repo == repo && 165 | r.PrId == prId, update, options); 166 | } 167 | 168 | public async Task UpdateShaAndClearStatusChecks(string owner, string repo, int prId, string sha) 169 | { 170 | var options = new FindOneAndUpdateOptions 171 | { 172 | IsUpsert = false, 173 | ReturnDocument = ReturnDocument.After 174 | }; 175 | var update = Builders.Update.Set(r => r.Sha, sha).Set(r => r.Checks, new List()); 176 | 177 | return await collection.FindOneAndUpdateAsync(r => r.Owner == owner && 178 | r.Repo == repo && 179 | r.PrId == prId, update, options); 180 | } 181 | 182 | public async Task Create(MergeRequest mergeRequest) 183 | { 184 | await collection.InsertOneAsync(mergeRequest); 185 | } 186 | 187 | public async Task Delete(string owner, string repo, int prId) => await collection.FindOneAndDeleteAsync(r => r.Owner == owner && r.Repo == repo && r.PrId == prId); 188 | } 189 | } -------------------------------------------------------------------------------- /Miro/Services/Github/EventHandlers/IssueCommentEventHandler.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using Microsoft.Extensions.Configuration; 7 | using Miro.Models.Github.IncomingEvents; 8 | using Miro.Models.Merge; 9 | using Miro.Models.Checks; 10 | using Miro.Services.Merge; 11 | using Miro.Services.Checks; 12 | using MongoDB.Bson; 13 | using MongoDB.Driver; 14 | using Serilog; 15 | using Miro.Services.Logger; 16 | using Miro.Services.Comments; 17 | using Miro.Models.Github.Responses; 18 | using Miro.Services.MiroConfig; 19 | using Miro.Models.MiroConfig; 20 | using System.Text.RegularExpressions; 21 | 22 | namespace Miro.Services.Github.EventHandlers 23 | { 24 | public class IssueCommentEventHandler : IWebhookEventHandler 25 | { 26 | private readonly CommentCreator commentCreator; 27 | private readonly MergeRequestsRepository mergeRequestRepository; 28 | private readonly MergeabilityValidator mergeabilityValidator; 29 | private readonly RepoConfigManager repoConfigManager; 30 | private readonly MiroMergeCheck miroMergeCheck; 31 | private readonly MergeOperations mergeOperations; 32 | private readonly ILogger logger = Log.ForContext(); 33 | 34 | public IssueCommentEventHandler( 35 | CommentCreator commentCreator, 36 | MergeRequestsRepository mergeRequestRepository, 37 | MergeabilityValidator mergeabilityValidator, 38 | RepoConfigManager repoConfigManager, 39 | MiroMergeCheck miroMergeCheck, 40 | MergeOperations mergeOperations) 41 | { 42 | this.commentCreator = commentCreator; 43 | this.mergeRequestRepository = mergeRequestRepository; 44 | this.mergeabilityValidator = mergeabilityValidator; 45 | this.repoConfigManager = repoConfigManager; 46 | this.miroMergeCheck = miroMergeCheck; 47 | this.mergeOperations = mergeOperations; 48 | } 49 | 50 | public async Task Handle(IssueCommentEvent issueCommentEvent) 51 | { 52 | var isCreatedAction = issueCommentEvent.Action.Equals("created", StringComparison.OrdinalIgnoreCase); 53 | if (!isCreatedAction) 54 | { 55 | logger.WithExtraData(new { issueEvent = issueCommentEvent.Action }).Information("Miro only handles new comments"); 56 | return new WebhookResponse(false, "Miro only handles new comments"); 57 | } 58 | 59 | var comment = issueCommentEvent.Comment.Body.Trim(); 60 | 61 | var regex = new Regex($"^.*({CommentsConsts.MergeCommand}|{CommentsConsts.CancelCommand}|{CommentsConsts.InfoCommand}|{CommentsConsts.WipCommand}).*$", RegexOptions.IgnoreCase); 62 | var match = regex.Match(comment); 63 | 64 | if (!match.Success) 65 | { 66 | logger.WithExtraData(new { comment }).Information("Comment doesn't contain a miro command, ignoring"); 67 | return new WebhookResponse(false, "Comment doesn't contain a miro command, ignoring"); 68 | } 69 | 70 | return await HandleMiroCommand(issueCommentEvent, match.Groups[1].Value.ToLower()); 71 | } 72 | 73 | private async Task HandleMiroCommand(IssueCommentEvent issueCommentEvent, string miroCommand) 74 | { 75 | var owner = issueCommentEvent.Repository.Owner.Login; 76 | var repo = issueCommentEvent.Repository.Name; 77 | var prId = issueCommentEvent.Issue.Number; 78 | 79 | logger.WithExtraData(new { miroCommand, owner, repo, prId }).Information($"Handling miro comment command"); 80 | 81 | var mergeRequest = await mergeRequestRepository.Get(owner, repo, prId); 82 | 83 | if (mergeRequest == null) 84 | { 85 | logger.WithExtraData(new { miroCommand, owner, repo, prId }).Warning($"Received miro command on unknown PR, Miro can't handle this"); 86 | return new WebhookResponse(false, "Received miro command on unknown PR, Miro can't handle this"); 87 | } 88 | 89 | 90 | switch (miroCommand) 91 | { 92 | case CommentsConsts.CancelCommand: 93 | return await HandleMiroCancelCommand(owner, repo, prId); 94 | case CommentsConsts.MergeCommand: 95 | return await HandleMiroMergeCommand(owner, repo, prId); 96 | case CommentsConsts.InfoCommand: 97 | return await PrintMergeInfo(mergeRequest); 98 | case CommentsConsts.WipCommand: 99 | return await HandleMiroWipCommand(owner, repo, prId); 100 | default: 101 | logger.WithExtraData(new { miroCommand }).Error("Comment was supposed to contain a miro command but did not"); 102 | return new WebhookResponse(false, "Comment doesn't contain a miro command, ignoring"); 103 | 104 | } 105 | } 106 | 107 | private async Task HandleMiroWipCommand(string owner, string repo, int prId) 108 | { 109 | await Task.WhenAll( 110 | mergeRequestRepository.UpdateMergeCommand(owner, repo, prId, false, DateTime.MaxValue), 111 | commentCreator.CreateComment(owner, repo, prId, CommentsConsts.MiroWipHeader, CommentsConsts.MiroWipBody)); 112 | return new WebhookResponse(true, "handled Miro wip command"); 113 | } 114 | 115 | private async Task HandleMiroCancelCommand(string owner, string repo, int prId) 116 | { 117 | await Task.WhenAll( 118 | mergeRequestRepository.UpdateMergeCommand(owner, repo, prId, false, DateTime.MaxValue), 119 | commentCreator.CreateComment(owner, repo, prId, CommentsConsts.MiroCancelHeader, CommentsConsts.MiroCancelBody)); 120 | return new WebhookResponse(true, "handled Miro cancel command"); 121 | } 122 | 123 | private async Task HandleMiroMergeCommand(string owner, string repo, int prId) 124 | { 125 | var mergeRequest = await mergeRequestRepository.UpdateMergeCommand(owner, repo, prId, true, DateTime.UtcNow); 126 | 127 | var config = await repoConfigManager.GetConfig(owner, repo); 128 | await PrintMergeInfoForMergeCommand(mergeRequest, config.IsWhitelistStrict(), config.Quiet); 129 | if (config.IsWhitelistStrict()) 130 | { 131 | logger.WithMergeRequestData(mergeRequest).Information("Repository has a whitelist-strict merge policy, resolving miro check on PR"); 132 | await miroMergeCheck.ResolveMiroMergeCheck(mergeRequest); 133 | } 134 | var merged = await mergeOperations.TryToMerge(mergeRequest); 135 | return new WebhookResponse(true, $"handled Miro merge command, did branch merge: {merged}"); 136 | } 137 | 138 | private async Task PrintMergeInfoForMergeCommand(MergeRequest mergeRequest, bool ignoreMiroMergeCheck = false, bool quiet = false) 139 | { 140 | if (mergeRequest == null) 141 | { 142 | logger.Warning($"Received PrintMergeInfo command from a null mergeRequest"); 143 | throw new Exception("Can not print info, PR is not defined"); 144 | } 145 | 146 | var owner = mergeRequest.Owner; 147 | var repo = mergeRequest.Repo; 148 | var prId = mergeRequest.PrId; 149 | var mergeabilityValidationErrors = await mergeabilityValidator.ValidateMergeability(mergeRequest, ignoreMiroMergeCheck); 150 | 151 | if (mergeabilityValidationErrors.Any()) 152 | { 153 | var errors = mergeabilityValidationErrors.Select(x => x.Error); 154 | await commentCreator.CreateListedComment(owner, repo, prId, CommentsConsts.MiroInfoMergeNotReady, errors.ToList()); 155 | } 156 | if (!quiet) 157 | { 158 | await commentCreator.CreateComment(owner, repo, prId, CommentsConsts.MiroInfoMergeReady, CommentsConsts.PrIsMergeableBody); 159 | } 160 | 161 | } 162 | 163 | private async Task PrintMergeInfo(MergeRequest mergeRequest) 164 | { 165 | if (mergeRequest == null) 166 | { 167 | logger.Warning($"Received PrintMergeInfo command from a null mergeRequest"); 168 | throw new Exception("Can not print info, PR is not defined"); 169 | } 170 | 171 | var owner = mergeRequest.Owner; 172 | var repo = mergeRequest.Repo; 173 | var prId = mergeRequest.PrId; 174 | var mergeabilityValidationErrors = await mergeabilityValidator.ValidateMergeability(mergeRequest); 175 | 176 | if (mergeabilityValidationErrors.Any()) 177 | { 178 | var errors = mergeabilityValidationErrors.Select(x => x.Error); 179 | await commentCreator.CreateListedComment(owner, repo, prId, CommentsConsts.MiroInfoMergeNotReady, errors.ToList()); 180 | return new WebhookResponse(true, $"handled Miro info command"); ; 181 | } 182 | await commentCreator.CreateComment(owner, repo, prId, CommentsConsts.MiroInfoMergeReady, CommentsConsts.PrIsMergeableBody); 183 | return new WebhookResponse(true, $"handled Miro info command"); 184 | } 185 | } 186 | } --------------------------------------------------------------------------------