Skip to content

Commit 1bf9e05

Browse files
Add MSBuild task for creating/updating pull requests
1 parent 1f596e1 commit 1bf9e05

File tree

6 files changed

+331
-1
lines changed

6 files changed

+331
-1
lines changed

Templating.sln

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
Microsoft Visual Studio Solution File, Format Version 12.00
33
# Visual Studio 15
4-
VisualStudioVersion = 15.0.26730.0
4+
VisualStudioVersion = 15.0.26730.16
55
MinimumVisualStudioVersion = 15.0.26124.0
66
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Templates.Test", "test\Templates.Test\Templates.Test.csproj", "{D43A4D24-D514-44C2-9438-54F6EDF58680}"
77
EndProject
@@ -23,6 +23,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.Web.Client
2323
EndProject
2424
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}"
2525
EndProject
26+
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{973DC5B6-710B-4FC8-AF20-E94B93859DE8}"
27+
EndProject
28+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PullRequestSubmitter", "tools\PullRequestSubmitter\PullRequestSubmitter.csproj", "{AFF8B079-5BA1-4DA8-9EAF-BEC8414F889A}"
29+
EndProject
2630
Global
2731
GlobalSection(SolutionConfigurationPlatforms) = preSolution
2832
Debug|Any CPU = Debug|Any CPU
@@ -65,6 +69,10 @@ Global
6569
{260EBA09-DEF5-429C-99BF-90CA1456A576}.Debug|Any CPU.Build.0 = Debug|Any CPU
6670
{260EBA09-DEF5-429C-99BF-90CA1456A576}.Release|Any CPU.ActiveCfg = Release|Any CPU
6771
{260EBA09-DEF5-429C-99BF-90CA1456A576}.Release|Any CPU.Build.0 = Release|Any CPU
72+
{AFF8B079-5BA1-4DA8-9EAF-BEC8414F889A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
73+
{AFF8B079-5BA1-4DA8-9EAF-BEC8414F889A}.Debug|Any CPU.Build.0 = Debug|Any CPU
74+
{AFF8B079-5BA1-4DA8-9EAF-BEC8414F889A}.Release|Any CPU.ActiveCfg = Release|Any CPU
75+
{AFF8B079-5BA1-4DA8-9EAF-BEC8414F889A}.Release|Any CPU.Build.0 = Release|Any CPU
6876
EndGlobalSection
6977
GlobalSection(SolutionProperties) = preSolution
7078
HideSolutionNode = FALSE
@@ -73,10 +81,12 @@ Global
7381
{ABC9D95C-7FBD-4F8D-827A-00942EA3D0C0} = {0AD6E692-E423-408C-B523-DAFB19412E4B}
7482
{B8EB8821-9B58-465A-9693-5F9289AA7B29} = {0AD6E692-E423-408C-B523-DAFB19412E4B}
7583
{62D00388-8824-4661-8CC8-8D8436FF97E6} = {0AD6E692-E423-408C-B523-DAFB19412E4B}
84+
{15806338-550D-4418-99AE-42FDAE03808A} = {973DC5B6-710B-4FC8-AF20-E94B93859DE8}
7685
{01E12D5E-8540-4BC8-9A54-41EDD55E762E} = {0AD6E692-E423-408C-B523-DAFB19412E4B}
7786
{402E62D1-7FD0-4E07-812C-0E385D98D6D9} = {0AD6E692-E423-408C-B523-DAFB19412E4B}
7887
{1731F6D9-1DFC-49D6-8F28-471194B1962C} = {0AD6E692-E423-408C-B523-DAFB19412E4B}
7988
{260EBA09-DEF5-429C-99BF-90CA1456A576} = {0AD6E692-E423-408C-B523-DAFB19412E4B}
89+
{AFF8B079-5BA1-4DA8-9EAF-BEC8414F889A} = {973DC5B6-710B-4FC8-AF20-E94B93859DE8}
8090
EndGlobalSection
8191
GlobalSection(ExtensibilityGlobals) = postSolution
8292
SolutionGuid = {E9B27B0D-4F85-431B-9C26-80CFE4393D36}

build/repo.targets

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,41 @@
11
<Project>
22
<Import Project="FixPackageOutputDirs.targets" />
33

4+
<UsingTask
5+
TaskName="PullRequestSubmitter.PullRequestTask"
6+
AssemblyFile="$(MSBuildThisFileDirectory)..\tools\PullRequestSubmitter\bin\Debug\netcoreapp2.0\PullRequestSubmitter.dll" />
7+
48
<Target Name="PushNuGetPackages">
59
<ItemGroup>
610
<PackagesToPublish Include="$(BuildDir)*.nupkg" />
711
</ItemGroup>
812
<PushNuGetPackages Packages="@(PackagesToPublish)" Feed="$(NuGetPublishFeed)" ApiKey="$(ApiKey)" />
913
</Target>
14+
15+
<Target Name="SendPullRequestToCliRepo">
16+
<PropertyGroup>
17+
<!-- Read the ASP.NET Core package version from a .deps.json file, because
18+
the CI server doesn't otherwise have that information to supply -->
19+
<DepsFilePath>$(MSBuildThisFileDirectory)..\tools\DependencyUpdater\bin\Debug\netstandard2.0\DependencyUpdater.deps.json</DepsFilePath>
20+
<DepsFileContent>$([System.IO.File]::ReadAllText('$(DepsFilePath)'))</DepsFileContent>
21+
<AspNetCoreRuntimePackageVersion>$([System.Text.RegularExpressions.Regex]::Match($(DepsFileContent), `\s+"Microsoft.AspNetCore": "([^"]+)"`).Groups[1].Value)</AspNetCoreRuntimePackageVersion>
22+
</PropertyGroup>
23+
24+
<ItemGroup>
25+
<PropertyUpdate Include="AspNetCoreRuntimePackageFolderName" NewValue="$(AspNetCoreRuntimePackageFolderName)" />
26+
<PropertyUpdate Include="AspNetCoreRuntimePackageVersion" NewValue="$(AspNetCoreRuntimePackageVersion)" />
27+
</ItemGroup>
28+
29+
<PullRequestTask
30+
ApiToken="$(GitHubApiToken)"
31+
UpstreamRepoOwner="$(UpstreamRepoOwner)"
32+
UpstreamRepoName="$(UpstreamRepoName)"
33+
UpstreamRepoBranch="$(UpstreamRepoBranch)"
34+
ForkRepoName="$(ForkRepoName)"
35+
ForkRepoBranch="$(ForkRepoBranch)"
36+
Message="Update ASP.NET Core to $(AspNetCoreRuntimePackageVersion)"
37+
FileToUpdate="build/DependencyVersions.props"
38+
PropertyUpdates="@(PropertyUpdate)" />
39+
</Target>
40+
1041
</Project>
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
using Octokit;
2+
using System;
3+
using System.Collections.Generic;
4+
using System.Diagnostics;
5+
using System.IO;
6+
using System.Linq;
7+
using System.Text;
8+
using System.Text.RegularExpressions;
9+
using System.Threading.Tasks;
10+
11+
namespace PullRequestSubmitter.Helpers
12+
{
13+
static class GitHubUtil
14+
{
15+
public static async Task<IDictionary<string, NewBlob>> GetEditsToCommit(
16+
GitHubClient client, Repository upstreamRepo, string baseSha,
17+
IEnumerable<PropertyUpdate> propertyUpdates)
18+
{
19+
// Find the file to update
20+
var existingTree = await client.Git.Tree.GetRecursive(upstreamRepo.Id, baseSha);
21+
22+
// Update the files' contents
23+
var result = new Dictionary<string, NewBlob>();
24+
var filesToUpdate = propertyUpdates.GroupBy(p => p.Filename);
25+
foreach (var fileToUpdate in filesToUpdate)
26+
{
27+
var fileContents = await GetFileContentsAsString(
28+
client, upstreamRepo, existingTree.Tree, fileToUpdate.Key);
29+
30+
foreach (var propToUpdate in fileToUpdate)
31+
{
32+
var propName = propToUpdate.PropertyName;
33+
var patternToReplace = new Regex($"<{propName}>[^<]+</{propName}>");
34+
if (!patternToReplace.IsMatch(fileContents))
35+
{
36+
throw new Exception($"The file {fileToUpdate.Key} does not contain a match for regex " + patternToReplace.ToString());
37+
}
38+
39+
fileContents = patternToReplace.Replace(
40+
fileContents,
41+
$"<{propName}>{propToUpdate.NewValue}</{propName}>");
42+
}
43+
44+
var newBlob = new NewBlob { Content = fileContents, Encoding = EncodingType.Utf8 };
45+
result.Add(fileToUpdate.Key, newBlob);
46+
}
47+
48+
return result;
49+
}
50+
51+
public static async Task<string> GetLatestCommitSha(
52+
GitHubClient client, Repository repo, string branchName)
53+
{
54+
var commitRef = await client.Git.Reference.Get(
55+
repo.Id,
56+
$"heads/{branchName}");
57+
return commitRef.Object.Sha;
58+
}
59+
60+
public static async Task<string> CommitModifiedFiles(
61+
GitHubClient client, Repository toRepo, string toBranchName, string parentCommitSha,
62+
IDictionary<string, NewBlob> modifiedFiles, string commitMessage)
63+
{
64+
// Build and commit a new tree representing the updated state
65+
var newTree = new NewTree { BaseTree = parentCommitSha };
66+
foreach (var kvp in modifiedFiles)
67+
{
68+
newTree.Tree.Remove(new NewTreeItem { Path = kvp.Key });
69+
newTree.Tree.Add(new NewTreeItem()
70+
{
71+
Type = TreeType.Blob,
72+
Mode = "100644",
73+
Sha = (await client.Git.Blob.Create(toRepo.Id, kvp.Value)).Sha,
74+
Path = kvp.Key
75+
});
76+
}
77+
var createdTree = await client.Git.Tree.Create(toRepo.Id, newTree);
78+
var commit = await client.Git.Commit.Create(
79+
toRepo.Id,
80+
new NewCommit(commitMessage, createdTree.Sha, parentCommitSha));
81+
82+
// Update the target branch to point to the new commit
83+
await client.Git.Reference.Update(
84+
toRepo.Id,
85+
$"heads/{toBranchName}",
86+
new ReferenceUpdate(commit.Sha, force: true));
87+
88+
return commit.Sha;
89+
}
90+
91+
public static async Task<Issue> FindExistingPullRequestToUpdate(
92+
GitHubClient client, User currentUser, Repository upstreamRepo,
93+
Repository forkRepo, string forkBranch)
94+
{
95+
// Search for candidate PRs (same author, still open, etc.)
96+
var fromBaseRef = $"{forkRepo.Owner.Login}:{forkBranch}";
97+
var searchInRepos = new RepositoryCollection();
98+
searchInRepos.Add(upstreamRepo.Owner.Login, upstreamRepo.Name);
99+
var searchRequest = new SearchIssuesRequest
100+
{
101+
Repos = searchInRepos,
102+
Type = IssueTypeQualifier.PullRequest,
103+
Author = currentUser.Login,
104+
State = ItemState.Open
105+
};
106+
var searchResults = await client.Search.SearchIssues(searchRequest);
107+
108+
// Of the candidates, find the highest-numbered one that is requesting a
109+
// pull from the same fork and branch. GitHub only allows there to be one
110+
// of these at any given time, but we're more likely to find it faster
111+
// by searching from newest to oldest.
112+
var candidates = searchResults.Items.OrderByDescending(item => item.Number);
113+
foreach (var prInfo in candidates)
114+
{
115+
var pr = await client.PullRequest.Get(upstreamRepo.Id, prInfo.Number);
116+
if (pr.Head?.Repository?.Id == forkRepo.Id && pr.Head?.Ref == forkBranch)
117+
{
118+
return prInfo;
119+
}
120+
}
121+
122+
return null;
123+
}
124+
125+
public static async Task<PullRequest> CreateNewPullRequest(
126+
GitHubClient client, Repository upstreamRepo, string upstreamBranch,
127+
Repository forkRepo, string forkBranch, string prBodyText)
128+
{
129+
var fromBaseRef = $"{forkRepo.Owner.Login}:{forkBranch}";
130+
var newPr = new NewPullRequest(
131+
prBodyText,
132+
fromBaseRef,
133+
upstreamBranch);
134+
return await client.PullRequest.Create(upstreamRepo.Id, newPr);
135+
}
136+
137+
public static async Task UpdateExistingPullRequestTitle(
138+
GitHubClient client, Repository upstreamRepo, int prNumber, string newTitle)
139+
{
140+
var updateInfo = new PullRequestUpdate { Title = newTitle };
141+
await client.PullRequest.Update(upstreamRepo.Id, prNumber, updateInfo);
142+
}
143+
144+
private static async Task<string> GetFileContentsAsString(
145+
GitHubClient client, Repository repo, IReadOnlyList<TreeItem> tree, string path)
146+
{
147+
var existingFile = tree.FirstOrDefault(item => item.Path == path);
148+
var blob = await client.Git.Blob.Get(repo.Id, existingFile.Sha);
149+
150+
switch (blob.Encoding.Value)
151+
{
152+
case EncodingType.Utf8:
153+
return blob.Content;
154+
case EncodingType.Base64:
155+
return Encoding.UTF8.GetString(Convert.FromBase64String(blob.Content));
156+
default:
157+
throw new InvalidDataException($"Unsupported encoding: {blob.Encoding.StringValue}");
158+
}
159+
}
160+
}
161+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
namespace PullRequestSubmitter.Helpers
2+
{
3+
class PropertyUpdate
4+
{
5+
public string Filename { get; set; }
6+
public string PropertyName { get; set; }
7+
public string NewValue { get; set; }
8+
}
9+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>netcoreapp2.0</TargetFramework>
5+
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
6+
</PropertyGroup>
7+
8+
<ItemGroup>
9+
<PackageReference Include="Microsoft.Build.Framework" />
10+
<PackageReference Include="Microsoft.Build.Utilities.Core" />
11+
<PackageReference Include="Octokit" Version="0.26.0" NoWarn="KRB4002" />
12+
</ItemGroup>
13+
14+
</Project>
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
using Microsoft.Build.Framework;
2+
using Octokit;
3+
using PullRequestSubmitter.Helpers;
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Linq;
7+
using System.Threading.Tasks;
8+
9+
namespace PullRequestSubmitter
10+
{
11+
public class PullRequestTask : Microsoft.Build.Utilities.Task
12+
{
13+
[Required] public string ApiToken { get; set; }
14+
[Required] public string UpstreamRepoOwner { get; set; }
15+
[Required] public string UpstreamRepoName { get; set; }
16+
[Required] public string UpstreamRepoBranch { get; set; }
17+
[Required] public string ForkRepoName { get; set; }
18+
[Required] public string ForkRepoBranch { get; set; }
19+
[Required] public string Message { get; set; }
20+
[Required] public string FileToUpdate { get; set; }
21+
[Required] public ITaskItem[] PropertyUpdates { get; set; }
22+
23+
public override bool Execute()
24+
{
25+
return ExecuteAsync().Result;
26+
}
27+
28+
private IEnumerable<PropertyUpdate> GetPropertyUpdates()
29+
{
30+
return PropertyUpdates.Select(item => new PropertyUpdate
31+
{
32+
Filename = FileToUpdate,
33+
PropertyName = item.ItemSpec,
34+
NewValue = item.GetMetadata("NewValue")
35+
});
36+
}
37+
38+
private async Task<bool> ExecuteAsync()
39+
{
40+
var client = new GitHubClient(new ProductHeaderValue("PullRequestSubmitter"))
41+
{
42+
Credentials = new Credentials(ApiToken),
43+
};
44+
45+
// Find the upstream repo and determine what edits we want to make
46+
LogHigh($"Finding upstream repo: {UpstreamRepoOwner}/{UpstreamRepoName}...");
47+
var upstreamRepo = await client.Repository.Get(UpstreamRepoOwner, UpstreamRepoName);
48+
var upstreamCommitSha = await GitHubUtil.GetLatestCommitSha(client, upstreamRepo, UpstreamRepoBranch);
49+
LogHigh($"Found upstream commit to update: {upstreamCommitSha} ({UpstreamRepoBranch})");
50+
var editsToCommit = await GitHubUtil.GetEditsToCommit(
51+
client, upstreamRepo, upstreamCommitSha, GetPropertyUpdates());
52+
if (editsToCommit.Any())
53+
{
54+
var filesList = string.Join('\n',
55+
editsToCommit.Select(e => " - " + e.Key));
56+
LogHigh($"Will apply edits to file(s):\n{filesList}");
57+
}
58+
else
59+
{
60+
Log.LogError("Found no edits to apply. Aborting.");
61+
return false;
62+
}
63+
64+
// Commit the edits into the fork repo, updating its head to point to a new tree
65+
// formed by updating the tree from the upstream SHA
66+
var currentUser = await client.User.Current();
67+
LogHigh($"Finding fork repo: {currentUser.Login}/{ForkRepoName}...");
68+
var forkRepo = await client.Repository.Get(currentUser.Login, ForkRepoName);
69+
var newCommitSha = await GitHubUtil.CommitModifiedFiles(
70+
client,
71+
forkRepo,
72+
ForkRepoBranch,
73+
upstreamCommitSha,
74+
editsToCommit,
75+
Message);
76+
LogHigh($"Committed edits. {currentUser.Login}/{ForkRepoName} branch {ForkRepoBranch} is now at {newCommitSha}");
77+
78+
// If applicable, submit a new PR
79+
LogHigh($"Checking if there is already an open PR we can update...");
80+
var prToUpdate = await GitHubUtil.FindExistingPullRequestToUpdate(
81+
client, currentUser, upstreamRepo, forkRepo, ForkRepoBranch);
82+
if (prToUpdate == null)
83+
{
84+
LogHigh($"No existing open PR found. Creating new PR...");
85+
var newPr = await GitHubUtil.CreateNewPullRequest(
86+
client, upstreamRepo, UpstreamRepoBranch, forkRepo, ForkRepoBranch, Message);
87+
LogHigh($"Created pull request #{newPr.Number} at {newPr.HtmlUrl}");
88+
}
89+
else
90+
{
91+
LogHigh($"Found existing PR #{prToUpdate.Number}. Updating details...");
92+
await GitHubUtil.UpdateExistingPullRequestTitle(
93+
client, upstreamRepo, prToUpdate.Number, Message);
94+
LogHigh($"Finished updating PR #{prToUpdate.Number} at {prToUpdate.HtmlUrl}");
95+
}
96+
97+
return true;
98+
}
99+
100+
private void LogHigh(string message)
101+
{
102+
Log.LogMessage(MessageImportance.High, message);
103+
}
104+
}
105+
}

0 commit comments

Comments
 (0)