From 1bf9e05dfd50161f569ef862f97626d6ef4a5426 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 26 Sep 2017 12:08:25 +0100 Subject: [PATCH] Add MSBuild task for creating/updating pull requests --- Templating.sln | 12 +- build/repo.targets | 31 ++++ .../Helpers/GitHubUtil.cs | 161 ++++++++++++++++++ .../Helpers/PropertyUpdate.cs | 9 + .../PullRequestSubmitter.csproj | 14 ++ tools/PullRequestSubmitter/PullRequestTask.cs | 105 ++++++++++++ 6 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 tools/PullRequestSubmitter/Helpers/GitHubUtil.cs create mode 100644 tools/PullRequestSubmitter/Helpers/PropertyUpdate.cs create mode 100644 tools/PullRequestSubmitter/PullRequestSubmitter.csproj create mode 100644 tools/PullRequestSubmitter/PullRequestTask.cs diff --git a/Templating.sln b/Templating.sln index 8fdd9e5dc6..c37115b9dc 100644 --- a/Templating.sln +++ b/Templating.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 -VisualStudioVersion = 15.0.26730.0 +VisualStudioVersion = 15.0.26730.16 MinimumVisualStudioVersion = 15.0.26124.0 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Templates.Test", "test\Templates.Test\Templates.Test.csproj", "{D43A4D24-D514-44C2-9438-54F6EDF58680}" EndProject @@ -23,6 +23,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.Web.Client EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.Web.ProjectTemplates.2.1", "src\Microsoft.DotNet.Web.ProjectTemplates.2.1\Microsoft.DotNet.Web.ProjectTemplates.2.1.csproj", "{260EBA09-DEF5-429C-99BF-90CA1456A576}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{973DC5B6-710B-4FC8-AF20-E94B93859DE8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PullRequestSubmitter", "tools\PullRequestSubmitter\PullRequestSubmitter.csproj", "{AFF8B079-5BA1-4DA8-9EAF-BEC8414F889A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -65,6 +69,10 @@ Global {260EBA09-DEF5-429C-99BF-90CA1456A576}.Debug|Any CPU.Build.0 = Debug|Any CPU {260EBA09-DEF5-429C-99BF-90CA1456A576}.Release|Any CPU.ActiveCfg = Release|Any CPU {260EBA09-DEF5-429C-99BF-90CA1456A576}.Release|Any CPU.Build.0 = Release|Any CPU + {AFF8B079-5BA1-4DA8-9EAF-BEC8414F889A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AFF8B079-5BA1-4DA8-9EAF-BEC8414F889A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AFF8B079-5BA1-4DA8-9EAF-BEC8414F889A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AFF8B079-5BA1-4DA8-9EAF-BEC8414F889A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -73,10 +81,12 @@ Global {ABC9D95C-7FBD-4F8D-827A-00942EA3D0C0} = {0AD6E692-E423-408C-B523-DAFB19412E4B} {B8EB8821-9B58-465A-9693-5F9289AA7B29} = {0AD6E692-E423-408C-B523-DAFB19412E4B} {62D00388-8824-4661-8CC8-8D8436FF97E6} = {0AD6E692-E423-408C-B523-DAFB19412E4B} + {15806338-550D-4418-99AE-42FDAE03808A} = {973DC5B6-710B-4FC8-AF20-E94B93859DE8} {01E12D5E-8540-4BC8-9A54-41EDD55E762E} = {0AD6E692-E423-408C-B523-DAFB19412E4B} {402E62D1-7FD0-4E07-812C-0E385D98D6D9} = {0AD6E692-E423-408C-B523-DAFB19412E4B} {1731F6D9-1DFC-49D6-8F28-471194B1962C} = {0AD6E692-E423-408C-B523-DAFB19412E4B} {260EBA09-DEF5-429C-99BF-90CA1456A576} = {0AD6E692-E423-408C-B523-DAFB19412E4B} + {AFF8B079-5BA1-4DA8-9EAF-BEC8414F889A} = {973DC5B6-710B-4FC8-AF20-E94B93859DE8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E9B27B0D-4F85-431B-9C26-80CFE4393D36} diff --git a/build/repo.targets b/build/repo.targets index e8f57fdaf7..7307370b90 100644 --- a/build/repo.targets +++ b/build/repo.targets @@ -1,10 +1,41 @@ + + + + + + + $(MSBuildThisFileDirectory)..\tools\DependencyUpdater\bin\Debug\netstandard2.0\DependencyUpdater.deps.json + $([System.IO.File]::ReadAllText('$(DepsFilePath)')) + $([System.Text.RegularExpressions.Regex]::Match($(DepsFileContent), `\s+"Microsoft.AspNetCore": "([^"]+)"`).Groups[1].Value) + + + + + + + + + + diff --git a/tools/PullRequestSubmitter/Helpers/GitHubUtil.cs b/tools/PullRequestSubmitter/Helpers/GitHubUtil.cs new file mode 100644 index 0000000000..538bfe4fc5 --- /dev/null +++ b/tools/PullRequestSubmitter/Helpers/GitHubUtil.cs @@ -0,0 +1,161 @@ +using Octokit; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace PullRequestSubmitter.Helpers +{ + static class GitHubUtil + { + public static async Task> GetEditsToCommit( + GitHubClient client, Repository upstreamRepo, string baseSha, + IEnumerable propertyUpdates) + { + // Find the file to update + var existingTree = await client.Git.Tree.GetRecursive(upstreamRepo.Id, baseSha); + + // Update the files' contents + var result = new Dictionary(); + var filesToUpdate = propertyUpdates.GroupBy(p => p.Filename); + foreach (var fileToUpdate in filesToUpdate) + { + var fileContents = await GetFileContentsAsString( + client, upstreamRepo, existingTree.Tree, fileToUpdate.Key); + + foreach (var propToUpdate in fileToUpdate) + { + var propName = propToUpdate.PropertyName; + var patternToReplace = new Regex($"<{propName}>[^<]+"); + if (!patternToReplace.IsMatch(fileContents)) + { + throw new Exception($"The file {fileToUpdate.Key} does not contain a match for regex " + patternToReplace.ToString()); + } + + fileContents = patternToReplace.Replace( + fileContents, + $"<{propName}>{propToUpdate.NewValue}"); + } + + var newBlob = new NewBlob { Content = fileContents, Encoding = EncodingType.Utf8 }; + result.Add(fileToUpdate.Key, newBlob); + } + + return result; + } + + public static async Task GetLatestCommitSha( + GitHubClient client, Repository repo, string branchName) + { + var commitRef = await client.Git.Reference.Get( + repo.Id, + $"heads/{branchName}"); + return commitRef.Object.Sha; + } + + public static async Task CommitModifiedFiles( + GitHubClient client, Repository toRepo, string toBranchName, string parentCommitSha, + IDictionary modifiedFiles, string commitMessage) + { + // Build and commit a new tree representing the updated state + var newTree = new NewTree { BaseTree = parentCommitSha }; + foreach (var kvp in modifiedFiles) + { + newTree.Tree.Remove(new NewTreeItem { Path = kvp.Key }); + newTree.Tree.Add(new NewTreeItem() + { + Type = TreeType.Blob, + Mode = "100644", + Sha = (await client.Git.Blob.Create(toRepo.Id, kvp.Value)).Sha, + Path = kvp.Key + }); + } + var createdTree = await client.Git.Tree.Create(toRepo.Id, newTree); + var commit = await client.Git.Commit.Create( + toRepo.Id, + new NewCommit(commitMessage, createdTree.Sha, parentCommitSha)); + + // Update the target branch to point to the new commit + await client.Git.Reference.Update( + toRepo.Id, + $"heads/{toBranchName}", + new ReferenceUpdate(commit.Sha, force: true)); + + return commit.Sha; + } + + public static async Task FindExistingPullRequestToUpdate( + GitHubClient client, User currentUser, Repository upstreamRepo, + Repository forkRepo, string forkBranch) + { + // Search for candidate PRs (same author, still open, etc.) + var fromBaseRef = $"{forkRepo.Owner.Login}:{forkBranch}"; + var searchInRepos = new RepositoryCollection(); + searchInRepos.Add(upstreamRepo.Owner.Login, upstreamRepo.Name); + var searchRequest = new SearchIssuesRequest + { + Repos = searchInRepos, + Type = IssueTypeQualifier.PullRequest, + Author = currentUser.Login, + State = ItemState.Open + }; + var searchResults = await client.Search.SearchIssues(searchRequest); + + // Of the candidates, find the highest-numbered one that is requesting a + // pull from the same fork and branch. GitHub only allows there to be one + // of these at any given time, but we're more likely to find it faster + // by searching from newest to oldest. + var candidates = searchResults.Items.OrderByDescending(item => item.Number); + foreach (var prInfo in candidates) + { + var pr = await client.PullRequest.Get(upstreamRepo.Id, prInfo.Number); + if (pr.Head?.Repository?.Id == forkRepo.Id && pr.Head?.Ref == forkBranch) + { + return prInfo; + } + } + + return null; + } + + public static async Task CreateNewPullRequest( + GitHubClient client, Repository upstreamRepo, string upstreamBranch, + Repository forkRepo, string forkBranch, string prBodyText) + { + var fromBaseRef = $"{forkRepo.Owner.Login}:{forkBranch}"; + var newPr = new NewPullRequest( + prBodyText, + fromBaseRef, + upstreamBranch); + return await client.PullRequest.Create(upstreamRepo.Id, newPr); + } + + public static async Task UpdateExistingPullRequestTitle( + GitHubClient client, Repository upstreamRepo, int prNumber, string newTitle) + { + var updateInfo = new PullRequestUpdate { Title = newTitle }; + await client.PullRequest.Update(upstreamRepo.Id, prNumber, updateInfo); + } + + private static async Task GetFileContentsAsString( + GitHubClient client, Repository repo, IReadOnlyList tree, string path) + { + var existingFile = tree.FirstOrDefault(item => item.Path == path); + var blob = await client.Git.Blob.Get(repo.Id, existingFile.Sha); + + switch (blob.Encoding.Value) + { + case EncodingType.Utf8: + return blob.Content; + case EncodingType.Base64: + return Encoding.UTF8.GetString(Convert.FromBase64String(blob.Content)); + default: + throw new InvalidDataException($"Unsupported encoding: {blob.Encoding.StringValue}"); + } + } + } +} diff --git a/tools/PullRequestSubmitter/Helpers/PropertyUpdate.cs b/tools/PullRequestSubmitter/Helpers/PropertyUpdate.cs new file mode 100644 index 0000000000..7f96cde2f5 --- /dev/null +++ b/tools/PullRequestSubmitter/Helpers/PropertyUpdate.cs @@ -0,0 +1,9 @@ +namespace PullRequestSubmitter.Helpers +{ + class PropertyUpdate + { + public string Filename { get; set; } + public string PropertyName { get; set; } + public string NewValue { get; set; } + } +} diff --git a/tools/PullRequestSubmitter/PullRequestSubmitter.csproj b/tools/PullRequestSubmitter/PullRequestSubmitter.csproj new file mode 100644 index 0000000000..0cf93739cd --- /dev/null +++ b/tools/PullRequestSubmitter/PullRequestSubmitter.csproj @@ -0,0 +1,14 @@ + + + + netcoreapp2.0 + true + + + + + + + + + diff --git a/tools/PullRequestSubmitter/PullRequestTask.cs b/tools/PullRequestSubmitter/PullRequestTask.cs new file mode 100644 index 0000000000..7420b04c72 --- /dev/null +++ b/tools/PullRequestSubmitter/PullRequestTask.cs @@ -0,0 +1,105 @@ +using Microsoft.Build.Framework; +using Octokit; +using PullRequestSubmitter.Helpers; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace PullRequestSubmitter +{ + public class PullRequestTask : Microsoft.Build.Utilities.Task + { + [Required] public string ApiToken { get; set; } + [Required] public string UpstreamRepoOwner { get; set; } + [Required] public string UpstreamRepoName { get; set; } + [Required] public string UpstreamRepoBranch { get; set; } + [Required] public string ForkRepoName { get; set; } + [Required] public string ForkRepoBranch { get; set; } + [Required] public string Message { get; set; } + [Required] public string FileToUpdate { get; set; } + [Required] public ITaskItem[] PropertyUpdates { get; set; } + + public override bool Execute() + { + return ExecuteAsync().Result; + } + + private IEnumerable GetPropertyUpdates() + { + return PropertyUpdates.Select(item => new PropertyUpdate + { + Filename = FileToUpdate, + PropertyName = item.ItemSpec, + NewValue = item.GetMetadata("NewValue") + }); + } + + private async Task ExecuteAsync() + { + var client = new GitHubClient(new ProductHeaderValue("PullRequestSubmitter")) + { + Credentials = new Credentials(ApiToken), + }; + + // Find the upstream repo and determine what edits we want to make + LogHigh($"Finding upstream repo: {UpstreamRepoOwner}/{UpstreamRepoName}..."); + var upstreamRepo = await client.Repository.Get(UpstreamRepoOwner, UpstreamRepoName); + var upstreamCommitSha = await GitHubUtil.GetLatestCommitSha(client, upstreamRepo, UpstreamRepoBranch); + LogHigh($"Found upstream commit to update: {upstreamCommitSha} ({UpstreamRepoBranch})"); + var editsToCommit = await GitHubUtil.GetEditsToCommit( + client, upstreamRepo, upstreamCommitSha, GetPropertyUpdates()); + if (editsToCommit.Any()) + { + var filesList = string.Join('\n', + editsToCommit.Select(e => " - " + e.Key)); + LogHigh($"Will apply edits to file(s):\n{filesList}"); + } + else + { + Log.LogError("Found no edits to apply. Aborting."); + return false; + } + + // Commit the edits into the fork repo, updating its head to point to a new tree + // formed by updating the tree from the upstream SHA + var currentUser = await client.User.Current(); + LogHigh($"Finding fork repo: {currentUser.Login}/{ForkRepoName}..."); + var forkRepo = await client.Repository.Get(currentUser.Login, ForkRepoName); + var newCommitSha = await GitHubUtil.CommitModifiedFiles( + client, + forkRepo, + ForkRepoBranch, + upstreamCommitSha, + editsToCommit, + Message); + LogHigh($"Committed edits. {currentUser.Login}/{ForkRepoName} branch {ForkRepoBranch} is now at {newCommitSha}"); + + // If applicable, submit a new PR + LogHigh($"Checking if there is already an open PR we can update..."); + var prToUpdate = await GitHubUtil.FindExistingPullRequestToUpdate( + client, currentUser, upstreamRepo, forkRepo, ForkRepoBranch); + if (prToUpdate == null) + { + LogHigh($"No existing open PR found. Creating new PR..."); + var newPr = await GitHubUtil.CreateNewPullRequest( + client, upstreamRepo, UpstreamRepoBranch, forkRepo, ForkRepoBranch, Message); + LogHigh($"Created pull request #{newPr.Number} at {newPr.HtmlUrl}"); + } + else + { + LogHigh($"Found existing PR #{prToUpdate.Number}. Updating details..."); + await GitHubUtil.UpdateExistingPullRequestTitle( + client, upstreamRepo, prToUpdate.Number, Message); + LogHigh($"Finished updating PR #{prToUpdate.Number} at {prToUpdate.HtmlUrl}"); + } + + return true; + } + + private void LogHigh(string message) + { + Log.LogMessage(MessageImportance.High, message); + } + } +}