Add MSBuild task for creating/updating pull requests

This commit is contained in:
Steve Sanderson 2017-09-26 12:08:25 +01:00
parent 1f596e127b
commit 1bf9e05dfd
6 changed files with 331 additions and 1 deletions

View File

@ -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}

View File

@ -1,10 +1,41 @@
<Project>
<Import Project="FixPackageOutputDirs.targets" />
<UsingTask
TaskName="PullRequestSubmitter.PullRequestTask"
AssemblyFile="$(MSBuildThisFileDirectory)..\tools\PullRequestSubmitter\bin\Debug\netcoreapp2.0\PullRequestSubmitter.dll" />
<Target Name="PushNuGetPackages">
<ItemGroup>
<PackagesToPublish Include="$(BuildDir)*.nupkg" />
</ItemGroup>
<PushNuGetPackages Packages="@(PackagesToPublish)" Feed="$(NuGetPublishFeed)" ApiKey="$(ApiKey)" />
</Target>
<Target Name="SendPullRequestToCliRepo">
<PropertyGroup>
<!-- Read the ASP.NET Core package version from a .deps.json file, because
the CI server doesn't otherwise have that information to supply -->
<DepsFilePath>$(MSBuildThisFileDirectory)..\tools\DependencyUpdater\bin\Debug\netstandard2.0\DependencyUpdater.deps.json</DepsFilePath>
<DepsFileContent>$([System.IO.File]::ReadAllText('$(DepsFilePath)'))</DepsFileContent>
<AspNetCoreRuntimePackageVersion>$([System.Text.RegularExpressions.Regex]::Match($(DepsFileContent), `\s+"Microsoft.AspNetCore": "([^"]+)"`).Groups[1].Value)</AspNetCoreRuntimePackageVersion>
</PropertyGroup>
<ItemGroup>
<PropertyUpdate Include="AspNetCoreRuntimePackageFolderName" NewValue="$(AspNetCoreRuntimePackageFolderName)" />
<PropertyUpdate Include="AspNetCoreRuntimePackageVersion" NewValue="$(AspNetCoreRuntimePackageVersion)" />
</ItemGroup>
<PullRequestTask
ApiToken="$(GitHubApiToken)"
UpstreamRepoOwner="$(UpstreamRepoOwner)"
UpstreamRepoName="$(UpstreamRepoName)"
UpstreamRepoBranch="$(UpstreamRepoBranch)"
ForkRepoName="$(ForkRepoName)"
ForkRepoBranch="$(ForkRepoBranch)"
Message="Update ASP.NET Core to $(AspNetCoreRuntimePackageVersion)"
FileToUpdate="build/DependencyVersions.props"
PropertyUpdates="@(PropertyUpdate)" />
</Target>
</Project>

View File

@ -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<IDictionary<string, NewBlob>> GetEditsToCommit(
GitHubClient client, Repository upstreamRepo, string baseSha,
IEnumerable<PropertyUpdate> propertyUpdates)
{
// Find the file to update
var existingTree = await client.Git.Tree.GetRecursive(upstreamRepo.Id, baseSha);
// Update the files' contents
var result = new Dictionary<string, NewBlob>();
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}>[^<]+</{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}</{propName}>");
}
var newBlob = new NewBlob { Content = fileContents, Encoding = EncodingType.Utf8 };
result.Add(fileToUpdate.Key, newBlob);
}
return result;
}
public static async Task<string> 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<string> CommitModifiedFiles(
GitHubClient client, Repository toRepo, string toBranchName, string parentCommitSha,
IDictionary<string, NewBlob> 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<Issue> 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<PullRequest> 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<string> GetFileContentsAsString(
GitHubClient client, Repository repo, IReadOnlyList<TreeItem> 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}");
}
}
}
}

View File

@ -0,0 +1,9 @@
namespace PullRequestSubmitter.Helpers
{
class PropertyUpdate
{
public string Filename { get; set; }
public string PropertyName { get; set; }
public string NewValue { get; set; }
}
}

View File

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp2.0</TargetFramework>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Build.Framework" />
<PackageReference Include="Microsoft.Build.Utilities.Core" />
<PackageReference Include="Octokit" Version="0.26.0" NoWarn="KRB4002" />
</ItemGroup>
</Project>

View File

@ -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<PropertyUpdate> GetPropertyUpdates()
{
return PropertyUpdates.Select(item => new PropertyUpdate
{
Filename = FileToUpdate,
PropertyName = item.ItemSpec,
NewValue = item.GetMetadata("NewValue")
});
}
private async Task<bool> 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);
}
}
}