diff --git a/Microsoft.DotNet.DockerTools.slnx b/Microsoft.DotNet.DockerTools.slnx index 0c8d96696..36bb37e88 100644 --- a/Microsoft.DotNet.DockerTools.slnx +++ b/Microsoft.DotNet.DockerTools.slnx @@ -7,6 +7,8 @@ + + diff --git a/eng/pipelines/docker-tools-packages-official.yml b/eng/pipelines/docker-tools-packages-official.yml index 09e1670d2..6164500a0 100644 --- a/eng/pipelines/docker-tools-packages-official.yml +++ b/eng/pipelines/docker-tools-packages-official.yml @@ -17,6 +17,7 @@ trigger: include: - eng/common/* - eng/pipelines/* + - src/GitAutomation/* - src/ImageBuilder/* - src/ImageBuilder.Models/* - Directory.Build.props diff --git a/eng/pipelines/docker-tools-packages-pr.yml b/eng/pipelines/docker-tools-packages-pr.yml index c32bc0e5c..36240f771 100644 --- a/eng/pipelines/docker-tools-packages-pr.yml +++ b/eng/pipelines/docker-tools-packages-pr.yml @@ -16,6 +16,7 @@ pr: include: - eng/common/* - eng/pipelines/* + - src/GitAutomation/* - src/ImageBuilder/* - src/ImageBuilder.Models/* - Directory.Build.props diff --git a/src/GitAutomation.Tests/DependencyInjectionTests.cs b/src/GitAutomation.Tests/DependencyInjectionTests.cs new file mode 100644 index 000000000..99bad789d --- /dev/null +++ b/src/GitAutomation.Tests/DependencyInjectionTests.cs @@ -0,0 +1,77 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.DotNet.GitAutomation.Tests; + +[TestClass] +public sealed class DependencyInjectionTests +{ + [TestMethod] + public void PullRequestManager_ResolvesWithDefaultServices() + { + ServiceCollection services = new(); + services.AddPullRequestAutomation( + new AutomationIdentity("bot", "bot@example.com"), + "token"); + + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + + PullRequestManager manager = serviceProvider.GetRequiredService(); + + Assert.IsNotNull(manager); + } + + [TestMethod] + public void PullRequestManager_ResolvesWithCustomServices() + { + ServiceCollection services = new(); + bool processRunnerResolved = false; + bool accessTokenProviderResolved = false; + services.AddSingleton(_ => + { + processRunnerResolved = true; + return new StubProcessRunner(); + }); + services.AddSingleton(_ => + { + accessTokenProviderResolved = true; + return new StaticGitAccessTokenProvider("token"); + }); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(new AutomationIdentity("bot", "bot@example.com")); + services.AddSingleton(); + + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + + PullRequestManager manager = serviceProvider.GetRequiredService(); + + Assert.IsNotNull(manager); + Assert.IsTrue(processRunnerResolved); + Assert.IsTrue(accessTokenProviderResolved); + } + + [TestMethod] + public void PullRequestManager_CanBeCreatedWithoutDependencyInjection() + { + PullRequestManager manager = new( + "token", + new AutomationIdentity("bot", "bot@example.com")); + + Assert.IsNotNull(manager); + } + + private sealed class StubProcessRunner : IProcessRunner + { + public Task RunAsync( + string? workingDirectory, + string fileName, + IEnumerable arguments, + CancellationToken cancellationToken) => + Task.FromResult(new ProcessResult(0, string.Empty, string.Empty)); + } +} diff --git a/src/GitAutomation.Tests/Microsoft.DotNet.GitAutomation.Tests.csproj b/src/GitAutomation.Tests/Microsoft.DotNet.GitAutomation.Tests.csproj new file mode 100644 index 000000000..86e5e2976 --- /dev/null +++ b/src/GitAutomation.Tests/Microsoft.DotNet.GitAutomation.Tests.csproj @@ -0,0 +1,27 @@ + + + + net9.0 + false + true + true + enable + enable + + + + true + false + MSTest + + + + + + + + + + + + diff --git a/src/GitAutomation.Tests/PullRequestPropertyTests.cs b/src/GitAutomation.Tests/PullRequestPropertyTests.cs new file mode 100644 index 000000000..79f119d23 --- /dev/null +++ b/src/GitAutomation.Tests/PullRequestPropertyTests.cs @@ -0,0 +1,217 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CsCheck; + +namespace Microsoft.DotNet.GitAutomation.Tests; + +[TestClass] +public sealed class PullRequestPlannerTests +{ + private const string Workspace = "test-workspace"; + + private static readonly AutomationIdentity AutomationIdentity = new("bot", "bot@example.com"); + + private static readonly Uri Url = new("https://github.com/dotnet/example/pull/1"); + + // A git object SHA-1: a 40-character lowercase hex hash (tree hash, commit SHA, etc.). + private static readonly Gen GenSha1 = Gen.String[Gen.Char["0123456789abcdef"], 40, 40]; + + private static readonly Gen GenPullRequestNumber = Gen.Int[1, 99999]; + + private static readonly Gen GenUpdateStrategy = + Gen.OneOfConst( + PullRequestUpdateStrategy.Append, + PullRequestUpdateStrategy.Replace); + + private static readonly Gen GenForeignCommitPolicy = + Gen.OneOfConst( + ForeignCommitPolicy.Proceed, + ForeignCommitPolicy.Stop); + + private static readonly Gen GenPullRequestState = + from key in Gen.OneOfConst("product-a", "product-b") + from title in Gen.OneOfConst("Title A", "Title B") + from body in Gen.OneOfConst("", "Body A", "Body B") + from targetBranch in Gen.OneOfConst("main", "nightly", "release", "release/1.0") + from treeHash in GenSha1 + select new PullRequestState(key, title, body, targetBranch, treeHash); + + private static readonly Gen GenTargetBranchState = + GenSha1.Select(treeHash => new TargetBranchState(treeHash)); + + private static readonly Gen GenAutomationCommit = + GenSha1.Select(sha => new CommitInfo(sha, AutomationIdentity.AuthorName, AutomationIdentity.AuthorEmail)); + + private static readonly Gen GenForeignCommit = + GenSha1.Select(sha => new CommitInfo(sha, "Person", "person@example.com")); + + private static readonly Gen GenRandomCommit = Gen.Frequency((3, GenAutomationCommit), (1, GenForeignCommit)); + + private static readonly Gen GenRandomCommits = GenRandomCommit.Array[1, 3]; + + private static Gen GenCommitsGuaranteedForeign = + from foreign in GenForeignCommit + from rest in GenRandomCommit.Array[0, 2] + from commits in Gen.Shuffle((CommitInfo[])[foreign, .. rest]) + select commits; + + // An arbitrary existing pull request, whose branch may include foreign commits. + private static readonly Gen GenExistingPullRequest = + from content in GenPullRequestState + from number in GenPullRequestNumber + from commits in GenRandomCommits + select new ExistingPullRequest(content, number, Url, commits); + + // Bundles the planner inputs for a single test case. + private sealed record PullRequestScenario( + PullRequestState DesiredState, + TargetBranchState TargetBranch, + ExistingPullRequest? ExistingPullRequest, + PullRequestUpdateStrategy UpdateStrategy, + ForeignCommitPolicy OnForeignCommits) + { + public IEnumerable Plan() => + Planner.Plan( + workspaceDirectory: Workspace, + identity: AutomationIdentity, + desiredState: DesiredState, + targetBranch: TargetBranch, + existingPullRequest: ExistingPullRequest, + updateStrategy: UpdateStrategy, + onForeignCommits: OnForeignCommits); + }; + + // If no PR exists, and there are changes to be made, then a new PR is + // always created. + [TestMethod] + public void ChangesWithNoPR_CreatesNewPR() + { + var scenario = + from desiredState in GenPullRequestState + from targetBranch in GenTargetBranchState + from updateStrategy in GenUpdateStrategy + from onForeignCommits in GenForeignCommitPolicy + // Collision should be very unlikely, but filter it out anyways + where desiredState.TreeHash != targetBranch.TreeHash + select new PullRequestScenario( + DesiredState: desiredState, + TargetBranch: targetBranch, + ExistingPullRequest: null, + UpdateStrategy: updateStrategy, + OnForeignCommits: onForeignCommits); + + scenario.Sample(s => s.Plan().OfType().Count() == 1); + } + + // If no PR exists, and there are changes to be made, the source branch is + // always force pushed to reset it to the state of the target branch. + [TestMethod] + public void ChangesWithNoPR_ResetsExistingBranch() + { + var scenario = + from desiredState in GenPullRequestState + from targetBranch in GenTargetBranchState + from updateStrategy in GenUpdateStrategy + from onForeignCommits in GenForeignCommitPolicy + // Collision should be very unlikely, but filter it out anyways + where desiredState.TreeHash != targetBranch.TreeHash + select new PullRequestScenario( + DesiredState: desiredState, + TargetBranch: targetBranch, + ExistingPullRequest: null, + UpdateStrategy: updateStrategy, + OnForeignCommits: onForeignCommits); + + scenario.Sample(s => s.Plan().OfType().Single().ForcePush); + } + + // For all scenarios where the desired tree is already present in an + // existing pull request, no actions are taken. + [TestMethod] + public void NoChanges_NoOp() + { + var scenario = + from desiredState in GenPullRequestState + from prNumber in GenPullRequestNumber + from existingCommits in GenRandomCommits + from targetBranch in GenTargetBranchState + from updateStrategy in GenUpdateStrategy + select new PullRequestScenario( + desiredState, + targetBranch, + new ExistingPullRequest(desiredState, prNumber, Url, existingCommits), + updateStrategy, + // Don't block on foreign commits + ForeignCommitPolicy.Proceed); + + scenario.Sample(s => !s.Plan().Any()); + } + + // For all scenarios where the desired tree already equals the target tree, + // nothing is pushed. + [TestMethod] + public void NoChanges_DoesNotPush() + { + var scenario = + from desiredState in GenPullRequestState + // 20% of scenarios will have no existing pull request + from existingPullRequest in Gen.Null(GenExistingPullRequest, 0.2) + from targetTree in GenSha1 + from updateStrategy in GenUpdateStrategy + // The base tree is the existing PR's head, or the target branch when none exists. + // Pin the desired tree to it so there is no content diff to push. + select new PullRequestScenario( + desiredState with { TreeHash = existingPullRequest?.Content.TreeHash ?? targetTree }, + new TargetBranchState(targetTree), + existingPullRequest, + updateStrategy, + // Don't block on foreign commits + ForeignCommitPolicy.Proceed); + + scenario.Sample(s => !s.Plan().OfType().Any()); + } + + // If there is already an open PR, never decide to create a second one. + [TestMethod] + public void ExistingPR_DoesNotCreateNewPR() + { + var scenario = + from desiredState in GenPullRequestState + from existingPullRequest in GenExistingPullRequest + from targetBranch in GenTargetBranchState + from updateStrategy in GenUpdateStrategy + from onForeignCommits in GenForeignCommitPolicy + select new PullRequestScenario( + desiredState, + targetBranch, + existingPullRequest, + updateStrategy, + onForeignCommits); + + scenario.Sample(s => !s.Plan().OfType().Any()); + } + + // For all scenarios where ForeignCommitPolicy.Stop is set, and there is a foreign commit on + // the PR branch, no action should be taken. + [TestMethod] + public void ExistingPR_StopsOnForeignCommits() + { + var scenario = + from desiredState in GenPullRequestState + from existingState in GenPullRequestState + from prNumber in GenPullRequestNumber + from commits in GenCommitsGuaranteedForeign + from targetBranch in GenTargetBranchState + from updateStrategy in GenUpdateStrategy + select new PullRequestScenario( + desiredState, + targetBranch, + new ExistingPullRequest(existingState, prNumber, Url, commits), + updateStrategy, + OnForeignCommits: ForeignCommitPolicy.Stop); + + scenario.Sample(s => !s.Plan().Any()); + } +} diff --git a/src/GitAutomation/Git.cs b/src/GitAutomation/Git.cs new file mode 100644 index 000000000..c56e8f132 --- /dev/null +++ b/src/GitAutomation/Git.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.GitAutomation; + +internal sealed class Git(IProcessRunner processRunner, ILogger logger) +{ + public async Task RunAsync( + string? secret, + string? workingDirectory, + CancellationToken cancellationToken, + params string[] args) + { + logger.LogDebug("Running: git {Args}", Redact(string.Join(' ', args), secret)); + + ProcessResult result = await processRunner.RunAsync( + workingDirectory, + fileName: "git", + args, + cancellationToken); + string output = result.StandardOutput; + string error = result.StandardError; + + // git writes progress and other human-readable messages to stderr even on success. + if (!string.IsNullOrWhiteSpace(error)) + { + logger.LogDebug( + """ + git stderr: + {Error} + """, + Redact(error.Trim(), secret) + ); + } + + if (!string.IsNullOrWhiteSpace(output)) + { + logger.LogDebug( + """ + git stdout: + {Output} + """, + Redact(output.Trim(), secret) + ); + } + + if (result.ExitCode != 0) + { + throw new InvalidOperationException( + $"Git command failed with exit code {result.ExitCode}.{Environment.NewLine}{error}"); + } + + return output.Trim(); + } + + /// + /// Scrubs a known (e.g. an access token embedded in a clone URL) + /// from text before it is logged, so tokens never reach the logs. + /// + private static string Redact(string text, string? secret) => + string.IsNullOrEmpty(secret) ? text : text.Replace(secret, "***"); +} diff --git a/src/GitAutomation/GitContext.cs b/src/GitAutomation/GitContext.cs new file mode 100644 index 000000000..dbcf9a567 --- /dev/null +++ b/src/GitAutomation/GitContext.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.GitAutomation; + +internal sealed class GitContext(string workspaceDirectory, Git git, ILogger logger) : IGitContext +{ + public string WorkspaceDirectory { get; } = workspaceDirectory; + + public async Task CommitAsync(string message, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(message); + + string status = await git.RunAsync(secret: null, WorkspaceDirectory, cancellationToken, "status", "--porcelain"); + if (string.IsNullOrWhiteSpace(status)) + { + logger.LogInformation("No changes to commit; working tree is clean."); + return; + } + + await git.RunAsync(secret: null, WorkspaceDirectory, cancellationToken, "add", "--all"); + await git.RunAsync(secret: null, WorkspaceDirectory, cancellationToken, "commit", "--message", message); + + string commit = await git.RunAsync(secret: null, WorkspaceDirectory, cancellationToken, "rev-parse", "HEAD"); + logger.LogInformation("Committed changes as {Commit}: \"{Message}\".", commit, message); + } +} diff --git a/src/GitAutomation/GitHub/GitHubRepo.cs b/src/GitAutomation/GitHub/GitHubRepo.cs new file mode 100644 index 000000000..f2e9c3e38 --- /dev/null +++ b/src/GitAutomation/GitHub/GitHubRepo.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.DotNet.GitAutomation.GitHub; + +/// +/// Identifies a GitHub repository by owner and name. +/// +/// The repository owner or organization. +/// The repository name. +public sealed record GitHubRepo(string Owner, string Name); + +internal static class GitHubRepoExtensions +{ + public static Uri GetCloneUrl(this GitHubRepo repo) => + new($"https://github.com/{repo.Owner}/{repo.Name}"); + + public static Uri GetAuthenticatedCloneUrl(this GitHubRepo repo, string token) => + new($"https://x-access-token:{token}@github.com/{repo.Owner}/{repo.Name}"); + + public static Uri GetCommitUrl(this GitHubRepo repo, string sha) => + new($"https://github.com/{repo.Owner}/{repo.Name}/commit/{sha}"); + + public static string GetHeadRef(this GitHubRepo repo, string branch) => $"{repo.Owner}:{branch}"; +} diff --git a/src/GitAutomation/GitHub/GitHubRepoHost.cs b/src/GitAutomation/GitHub/GitHubRepoHost.cs new file mode 100644 index 000000000..2d9fca1b9 --- /dev/null +++ b/src/GitAutomation/GitHub/GitHubRepoHost.cs @@ -0,0 +1,206 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Extensions.Logging; +using Octokit; + +namespace Microsoft.DotNet.GitAutomation.GitHub; + +internal sealed class GitHubRepoHost( + GitHubRepo targetRepo, + GitHubRepo sourceRepo, + string token, + IGitHubClient client, + ILoggerFactory loggerFactory, + Git git +) : IRepoHost +{ + private readonly ILogger _logger = loggerFactory.CreateLogger(); + + public async Task GetPullRequest(string key, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var query = new PullRequestRequest + { + Head = sourceRepo.GetHeadRef(key), + State = ItemStateFilter.Open, + }; + + IReadOnlyList pullRequests = + await client.PullRequest.GetAllForRepository(targetRepo.Owner, targetRepo.Name, query); + + if (pullRequests.Count == 0) + { + _logger.LogDebug( + "No open pull request with head '{Head}' in {Owner}/{Name}.", + sourceRepo.GetHeadRef(key), + targetRepo.Owner, + targetRepo.Name); + + return null; + } + + if (pullRequests.Count > 1) + { + throw new InvalidOperationException( + $"Expected at most one open pull request with head '{sourceRepo.GetHeadRef(key)}' " + + $"in {targetRepo.Owner}/{targetRepo.Name}, but found {pullRequests.Count}."); + } + + PullRequest pullRequest = pullRequests[0]; + cancellationToken.ThrowIfCancellationRequested(); + Commit headCommit = await client.Git.Commit.Get(sourceRepo.Owner, sourceRepo.Name, pullRequest.Head.Sha); + + cancellationToken.ThrowIfCancellationRequested(); + IReadOnlyList pullRequestCommits = + await client.PullRequest.Commits(targetRepo.Owner, targetRepo.Name, pullRequest.Number); + + IReadOnlyList commits = pullRequestCommits + .Select(commit => new CommitInfo(commit.Sha, commit.Commit.Author.Name, commit.Commit.Author.Email)) + .ToArray(); + + _logger.LogDebug( + "Found open pull request #{Number} with head '{Head}' ({CommitCount} commit(s)).", + pullRequest.Number, + sourceRepo.GetHeadRef(key), + commits.Count); + + var pullRequestState = new PullRequestState( + key, + pullRequest.Title, + pullRequest.Body ?? string.Empty, + pullRequest.Base.Ref, + headCommit.Tree.Sha); + + return new ExistingPullRequest( + pullRequestState, + pullRequest.Number, + new Uri(pullRequest.HtmlUrl), + commits + ); + } + + public async Task> ExecuteAsync(IEnumerable operations, CancellationToken cancellationToken) + { + List results = []; + + foreach (IOperation operation in operations) + { + cancellationToken.ThrowIfCancellationRequested(); + + IOperationResult result = operation switch + { + PushCommitsOperation push => await PushAsync(push, cancellationToken), + CreatePullRequestOperation create => await CreatePullRequestAsync(create, cancellationToken), + UpdateTitleOperation updateTitle => await UpdateTitleAsync(updateTitle, cancellationToken), + UpdateBodyOperation updateBody => await UpdateBodyAsync(updateBody, cancellationToken), + UpdateBaseBranchOperation updateBase => await UpdateBaseBranchAsync(updateBase, cancellationToken), + _ => throw new InvalidOperationException($"Unknown operation type '{operation.GetType()}'."), + }; + + results.Add(result); + } + + return results; + } + + private async Task PushAsync(PushCommitsOperation operation, CancellationToken cancellationToken) + { + string authUrl = sourceRepo.GetAuthenticatedCloneUrl(token).AbsoluteUri; + string branch = operation.SourceBranch; + string dir = operation.WorkspaceDirectory; + string remoteRef = $"refs/heads/{branch}"; + + string lsRemote = await git.RunAsync(token, dir, cancellationToken, ["ls-remote", "--heads", authUrl, remoteRef]); + string? existingLine = lsRemote + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault(line => line.EndsWith($"\t{remoteRef}", StringComparison.Ordinal)); + + string fromSha = existingLine is null ? string.Empty : existingLine.Split('\t')[0]; + string toSha = await git.RunAsync(secret: null, dir, cancellationToken, "rev-parse", "HEAD"); + + bool forcePush = operation.ForcePush; + string[] pushArgs = forcePush + ? ["push", "--force", authUrl, $"HEAD:{remoteRef}"] + : ["push", authUrl, $"HEAD:{remoteRef}"]; + + _logger.LogInformation( + "Pushing commit {ToSha} to branch '{Branch}' in {Owner}/{Name}{Force}.", + toSha, branch, sourceRepo.Owner, sourceRepo.Name, forcePush ? " (force)" : string.Empty); + + await git.RunAsync(token, dir, cancellationToken, pushArgs); + + Uri commitUrl = sourceRepo.GetCommitUrl(toSha); + _logger.LogInformation( + "Pushed branch '{Branch}' from {FromSha} to {ToSha}: {Url}", + branch, fromSha.Length == 0 ? "(new branch)" : fromSha, toSha, commitUrl); + + return new CommitsPushed(branch, fromSha, toSha, commitUrl); + } + + private async Task CreatePullRequestAsync(CreatePullRequestOperation operation, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var head = sourceRepo.GetHeadRef(operation.SourceBranch); + var newPullRequest = new NewPullRequest(operation.Title, head, operation.TargetBranch) + { + Body = operation.Body, + }; + + _logger.LogInformation( + "Creating pull request '{Title}' from '{Head}' into '{Base}' in {Owner}/{Name}.", + operation.Title, + head, + operation.TargetBranch, + targetRepo.Owner, + targetRepo.Name); + + PullRequest created = await client.PullRequest.Create(targetRepo.Owner, targetRepo.Name, newPullRequest); + + _logger.LogInformation("Created pull request #{Number}: {Url}.", created.Number, created.HtmlUrl); + return new PullRequestCreated(created.Number, new Uri(created.HtmlUrl)); + } + + private async Task UpdateTitleAsync(UpdateTitleOperation operation, CancellationToken cancellationToken) + { + _logger.LogInformation( + "Updating title of pull request #{Number} to '{Title}'.", + operation.Number, + operation.Title); + + await UpdatePullRequestAsync(operation.Number, cancellationToken, title: operation.Title); + + _logger.LogInformation("Updated title of pull request #{Number}.", operation.Number); + return new TitleUpdated(operation.Number, operation.Title); + } + + private async Task UpdateBodyAsync(UpdateBodyOperation operation, CancellationToken cancellationToken) + { + _logger.LogInformation("Updating body of pull request #{Number}.", operation.Number); + await UpdatePullRequestAsync(operation.Number, cancellationToken, body: operation.Body); + _logger.LogInformation("Updated body of pull request #{Number}.", operation.Number); + return new BodyUpdated(operation.Number, operation.Body); + } + + private async Task UpdateBaseBranchAsync(UpdateBaseBranchOperation operation, CancellationToken cancellationToken) + { + _logger.LogInformation( + "Updating base branch of pull request #{Number} to '{TargetBranch}'.", + operation.Number, + operation.TargetBranch); + + await UpdatePullRequestAsync(operation.Number, cancellationToken, baseBranch: operation.TargetBranch); + + _logger.LogInformation("Updated base branch of pull request #{Number}.", operation.Number); + return new BaseBranchUpdated(operation.Number, operation.TargetBranch); + } + + private async Task UpdatePullRequestAsync(int number, CancellationToken cancellationToken, string? title = null, string? body = null, string? baseBranch = null) + { + cancellationToken.ThrowIfCancellationRequested(); + var update = new PullRequestUpdate { Title = title, Body = body, Base = baseBranch }; + await client.PullRequest.Update(targetRepo.Owner, targetRepo.Name, number, update); + } +} diff --git a/src/GitAutomation/GitWorkspace.cs b/src/GitAutomation/GitWorkspace.cs new file mode 100644 index 000000000..f134e0c39 --- /dev/null +++ b/src/GitAutomation/GitWorkspace.cs @@ -0,0 +1,85 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.GitAutomation; + +internal sealed class GitWorkspace(string directory, ILogger logger) : IDisposable +{ + public string WorkingDirectory { get; } = directory; + + public static async Task CloneAsync( + Git git, + ILogger logger, + Uri cloneUrl, + string branch, + string authorName, + string authorEmail, + CancellationToken ct) + { + string directory = Path.Combine(Path.GetTempPath(), $"git-workspace-{Path.GetRandomFileName()}"); + + // The clone URL embeds the access token as "x-access-token:TOKEN"; scrub that from logs. + string secret = cloneUrl.UserInfo; + + var runGit = async (string[] args, string? directory = null) => + await git.RunAsync(secret, directory, ct, args); + + try + { + await runGit([ + "clone", + "--filter=blob:none", + "--single-branch", + "--no-tags", + "--branch", + branch, + cloneUrl.AbsoluteUri, + directory, + ]); + + await runGit(["config", "user.name", authorName], directory); + await runGit(["config", "user.email", authorEmail], directory); + } + catch (Exception exception) when (Directory.Exists(directory)) + { + logger.LogWarning(exception, "Clone into {Directory} failed; cleaning up.", directory); + DeleteDirectory(logger, directory); + throw; + } + + return new GitWorkspace(directory, logger); + } + + public void Dispose() + { + DeleteDirectory(logger, WorkingDirectory); + } + + private static void DeleteDirectory(ILogger logger, string workingDirectory) + { + if (!Directory.Exists(workingDirectory)) + { + return; + } + + logger.LogInformation("Cleaning up temporary workspace {Directory}.", workingDirectory); + + try + { + // git marks objects under .git as read-only, which blocks Directory.Delete on Windows. + // Clear the read-only attribute on every file first. + foreach (string file in Directory.EnumerateFiles(workingDirectory, "*", SearchOption.AllDirectories)) + File.SetAttributes(file, FileAttributes.Normal); + + Directory.Delete(workingDirectory, recursive: true); + } + catch (Exception exception) + { + // Best effort; ignore any failures cleaning up the temporary workspace. + logger.LogWarning(exception, "Failed to delete temporary workspace {Directory}.", workingDirectory); + } + } +} diff --git a/src/GitAutomation/IGitAccessTokenProvider.cs b/src/GitAutomation/IGitAccessTokenProvider.cs new file mode 100644 index 000000000..c8e02f0b2 --- /dev/null +++ b/src/GitAutomation/IGitAccessTokenProvider.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.DotNet.GitAutomation; + +/// +/// Provides access tokens for repository host operations. +/// +public interface IGitAccessTokenProvider +{ + /// + /// Gets an access token that is valid for the current operation. + /// + /// A token that cancels token acquisition. + /// An access token. + ValueTask GetTokenAsync(CancellationToken cancellationToken); +} diff --git a/src/GitAutomation/IGitContext.cs b/src/GitAutomation/IGitContext.cs new file mode 100644 index 000000000..aebb162ca --- /dev/null +++ b/src/GitAutomation/IGitContext.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.DotNet.GitAutomation; + +/// +/// Provides git operations available to a pull request definition while it applies changes. +/// +public interface IGitContext +{ + /// + /// The local working directory where changes should be applied. + /// + string WorkspaceDirectory { get; } + + /// + /// Commits the current workspace changes using the specified commit message. + /// + /// The commit message. + /// A token that cancels the commit operation. + Task CommitAsync(string message, CancellationToken cancellationToken); +} diff --git a/src/GitAutomation/IProcessRunner.cs b/src/GitAutomation/IProcessRunner.cs new file mode 100644 index 000000000..1df58d2e0 --- /dev/null +++ b/src/GitAutomation/IProcessRunner.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.DotNet.GitAutomation; + +/// +/// Runs external processes and captures their output. +/// +public interface IProcessRunner +{ + /// + /// Runs an external process. + /// + /// + /// The process working directory, or to use the current directory. + /// + /// The executable to run. + /// The arguments passed to the executable. + /// A token that cancels the process. + /// The process exit code and captured output. + Task RunAsync( + string? workingDirectory, + string fileName, + IEnumerable arguments, + CancellationToken cancellationToken); +} + +/// +/// The result of running an external process. +/// +/// The process exit code. +/// The captured standard output. +/// The captured standard error. +public sealed record ProcessResult(int ExitCode, string StandardOutput, string StandardError); diff --git a/src/GitAutomation/IRepoHost.cs b/src/GitAutomation/IRepoHost.cs new file mode 100644 index 000000000..b9ea359a7 --- /dev/null +++ b/src/GitAutomation/IRepoHost.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.DotNet.GitAutomation; + +internal interface IRepoHost +{ + Task GetPullRequest(string key, CancellationToken cancellationToken); + Task> ExecuteAsync(IEnumerable operations, CancellationToken cancellationToken); +} diff --git a/src/GitAutomation/Microsoft.DotNet.GitAutomation.csproj b/src/GitAutomation/Microsoft.DotNet.GitAutomation.csproj new file mode 100644 index 000000000..5df224fb9 --- /dev/null +++ b/src/GitAutomation/Microsoft.DotNet.GitAutomation.csproj @@ -0,0 +1,42 @@ + + + + net9.0 + enable + true + true + enable + + + + true + + $(NoWarn.Replace(';1591', '')) + + + + true + 0 + 1 + 0 + Declarative git automation library. + README.md + + false + + + + + + + + + + + + + + + + + diff --git a/src/GitAutomation/Models.cs b/src/GitAutomation/Models.cs new file mode 100644 index 000000000..4d33cc7de --- /dev/null +++ b/src/GitAutomation/Models.cs @@ -0,0 +1,169 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.RegularExpressions; + +namespace Microsoft.DotNet.GitAutomation; + +/// +/// Defines the desired pull request content and the changes that produce it. +/// +/// The source branch name used to identify and update the pull request. +/// The desired pull request title. +/// The desired pull request body. +/// The branch the pull request targets. +/// Applies the desired workspace changes before the automation commits them. +public sealed partial record PullRequestDefinition( + string Key, + string Title, + string Body, + string TargetBranch, + Func ApplyChanges) +{ + private string _key = ValidateKey(Key); + + /// + /// The source branch name used to identify and update the pull request. + /// + public string Key + { + get => _key; + init => _key = ValidateKey(value); + } + + private static string ValidateKey(string key) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key, nameof(Key)); + + bool hasValidComponents = key + .Split('/') + .All(component => + ValidKeyComponentRegex.IsMatch(component) + && !component.EndsWith(".lock", StringComparison.Ordinal)); + + if (key.StartsWith('-') || !hasValidComponents) + { + throw new ArgumentException( + $"'{key}' is not a valid pull request key. Use slash-separated components containing " + + "ASCII letters, digits, underscores, dashes, and periods. Periods must separate non-empty " + + "groups, components cannot end in '.lock', and the key cannot start with a dash.", + nameof(Key)); + } + + return key; + } + + // Matches one slash-separated component containing ASCII letters, digits, underscores, + // and dashes, with periods allowed only between non-empty groups. + [GeneratedRegex(@"^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$", RegexOptions.CultureInvariant)] + private static partial Regex ValidKeyComponentRegex { get; } +} + +internal sealed record PullRequestState(string Key, string Title, string Body, string TargetBranch, string TreeHash); + +/// +/// Determines how commits are pushed when updating an existing pull request branch. +/// +public enum PullRequestUpdateStrategy +{ + /// + /// Add the automation's new commits on top of the branch's existing commits without force-pushing. + /// + Append, + + /// + /// Overwrite the branch with exactly the automation's commits by force-pushing. + /// + Replace, +} + +/// +/// Determines how existing pull request commits from other authors are handled. +/// +public enum ForeignCommitPolicy +{ + /// + /// Apply the update strategy regardless of who authored the branch's existing commits. + /// + Proceed, + + /// + /// Give up without modifying the branch if it contains commits not authored by the automation. + /// + Stop, +} + +/// +/// The action a took to reconcile a pull request. +/// +public enum PullRequestAction +{ + /// + /// A new pull request was opened. + /// + Created, + + /// + /// An existing pull request was updated (commits pushed and/or metadata changed). + /// + Updated, + + /// + /// The pull request already matched the definition, so nothing was changed. + /// + NoChange, +} + +/// +/// The result of a pull request automation. +/// +/// What action was taken. +/// +/// The URL of the pull request if one was created or already exists. +/// Null if one didn't already exist and no action was needed. +/// +public sealed record PullRequestResult(PullRequestAction Action, Uri? Url); + +/// +/// An existing pull request as observed on the host: its plus +/// host-assigned facts that only exist once it has been opened. is an +/// output-only convenience for callers; the planner deliberately ignores it so it can +/// never influence planning. +/// +internal sealed record ExistingPullRequest(PullRequestState Content, int Number, Uri Url, IReadOnlyList Commits); + +/// +/// The observed state of the branch a new pull request would be created from. +/// When no pull request exists yet, its tree is the base we diff the desired +/// tree against to decide whether there is anything to propose. +/// +internal sealed record TargetBranchState(string TreeHash); + +/// +/// The automation's git identity, used to distinguish its own commits from foreign ones. +/// +/// The git author name used for automation commits. +/// The git author email used for automation commits. +public sealed record AutomationIdentity(string AuthorName, string AuthorEmail); + +/// +/// A single commit observed on an existing pull request's branch. +/// +internal sealed record CommitInfo(string Sha, string AuthorName, string AuthorEmail); + +// TODO: Use C# 15 unions after .NET 11's release +internal interface IOperation; +internal sealed record PushCommitsOperation(string WorkspaceDirectory, string SourceBranch, bool ForcePush) : IOperation; +internal sealed record CreatePullRequestOperation(string Title, string Body, string SourceBranch, string TargetBranch) : IOperation; +internal sealed record UpdateTitleOperation(int Number, string Title) : IOperation; +internal sealed record UpdateBodyOperation(int Number, string Body) : IOperation; +internal sealed record UpdateBaseBranchOperation(int Number, string TargetBranch) : IOperation; + +// TODO: Use C# 15 unions after .NET 11's release +internal interface IOperationResult; +internal sealed record CommitsPushed(string Branch, string FromSha, string ToSha, Uri Url) : IOperationResult; +internal sealed record PullRequestCreated(int Number, Uri Url) : IOperationResult; +internal sealed record TitleUpdated(int Number, string Title) : IOperationResult; +internal sealed record BodyUpdated(int Number, string Body) : IOperationResult; +internal sealed record BaseBranchUpdated(int Number, string TargetBranch) : IOperationResult; diff --git a/src/GitAutomation/Planner.cs b/src/GitAutomation/Planner.cs new file mode 100644 index 000000000..6f09c65d9 --- /dev/null +++ b/src/GitAutomation/Planner.cs @@ -0,0 +1,84 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.DotNet.GitAutomation; + +internal static class Planner +{ + public static IEnumerable Plan( + string workspaceDirectory, + AutomationIdentity identity, + PullRequestState desiredState, + TargetBranchState targetBranch, + ExistingPullRequest? existingPullRequest, + PullRequestUpdateStrategy updateStrategy, + ForeignCommitPolicy onForeignCommits + ) + { + // Give up without producing any operations when an existing branch contains + // foreign commits and the policy says to stop. + if (existingPullRequest is not null + && onForeignCommits == ForeignCommitPolicy.Stop + && HasForeignCommits(existingPullRequest, identity)) + { + return []; + } + + // The base we compare the desired tree against: the existing pull request's + // head when one exists, otherwise the target branch we'd branch from. + string baseTreeHash = existingPullRequest?.Content.TreeHash ?? targetBranch.TreeHash; + bool hasContentDiff = desiredState.TreeHash != baseTreeHash; + + List operations = []; + + if (hasContentDiff) + { + bool forcePush = existingPullRequest is null || updateStrategy == PullRequestUpdateStrategy.Replace; + + operations.Add(new PushCommitsOperation( + workspaceDirectory, + desiredState.Key, + forcePush)); + } + + if (existingPullRequest is null) + { + // Only open a pull request when there is actually a diff to propose. + if (hasContentDiff) + { + operations.Add(new CreatePullRequestOperation( + Title: desiredState.Title, + Body: desiredState.Body, + SourceBranch: desiredState.Key, + TargetBranch: desiredState.TargetBranch)); + } + + return operations; + } + + if (desiredState.Title != existingPullRequest.Content.Title) + { + UpdateTitleOperation updateTitle = new(existingPullRequest.Number, desiredState.Title); + operations.Add(updateTitle); + } + + if (desiredState.Body != existingPullRequest.Content.Body) + { + UpdateBodyOperation updateBody = new(existingPullRequest.Number, desiredState.Body); + operations.Add(updateBody); + } + + if (desiredState.TargetBranch != existingPullRequest.Content.TargetBranch) + { + UpdateBaseBranchOperation updateBase = new(existingPullRequest.Number, desiredState.TargetBranch); + operations.Add(updateBase); + } + + return operations; + } + + private static bool HasForeignCommits(ExistingPullRequest existing, AutomationIdentity identity) => + existing.Commits.Any(commit => + !string.Equals(commit.AuthorEmail, identity.AuthorEmail, StringComparison.OrdinalIgnoreCase)); +} diff --git a/src/GitAutomation/ProcessRunner.cs b/src/GitAutomation/ProcessRunner.cs new file mode 100644 index 000000000..2fbf3bf71 --- /dev/null +++ b/src/GitAutomation/ProcessRunner.cs @@ -0,0 +1,73 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.GitAutomation; + +/// +/// Runs external processes using . +/// +/// The logger used to report process cleanup failures. +public sealed class ProcessRunner(ILogger logger) : IProcessRunner +{ + /// + public async Task RunAsync( + string? workingDirectory, + string fileName, + IEnumerable arguments, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fileName); + ArgumentNullException.ThrowIfNull(arguments); + + ProcessStartInfo startInfo = new(fileName) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + if (workingDirectory is not null) + { + startInfo.WorkingDirectory = workingDirectory; + } + + foreach (string argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using Process process = Process.Start(startInfo) + ?? throw new InvalidOperationException($"Failed to start process '{startInfo.FileName}'."); + + Task outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + Task errorTask = process.StandardError.ReadToEndAsync(cancellationToken); + + try + { + await process.WaitForExitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + try + { + process.Kill(entireProcessTree: true); + } + catch (Exception exception) + { + // The process may have already exited. + logger.LogWarning(exception, "Failed to kill process after cancellation."); + } + + throw; + } + + return new ProcessResult( + process.ExitCode, + await outputTask, + await errorTask); + } +} diff --git a/src/GitAutomation/ProcessRunnerExtensions.cs b/src/GitAutomation/ProcessRunnerExtensions.cs new file mode 100644 index 000000000..190e92f0c --- /dev/null +++ b/src/GitAutomation/ProcessRunnerExtensions.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.DotNet.GitAutomation; + +/// +/// Convenience methods for running external processes. +/// +public static class ProcessRunnerExtensions +{ + /// + /// Runs an external process in the current working directory. + /// + /// The process runner. + /// The executable to run. + /// The arguments passed to the executable. + /// A token that cancels the process. + /// The process exit code and captured output. + public static Task RunAsync( + this IProcessRunner processRunner, + string fileName, + IEnumerable arguments, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(processRunner); + return processRunner.RunAsync( + workingDirectory: null, + fileName, + arguments, + cancellationToken); + } +} diff --git a/src/GitAutomation/PullRequestAutomationServiceCollectionExtensions.cs b/src/GitAutomation/PullRequestAutomationServiceCollectionExtensions.cs new file mode 100644 index 000000000..e47c84b73 --- /dev/null +++ b/src/GitAutomation/PullRequestAutomationServiceCollectionExtensions.cs @@ -0,0 +1,61 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.GitAutomation; + +/// +/// Registers services for declarative pull request automation. +/// +public static class PullRequestAutomationServiceCollectionExtensions +{ + /// + /// Registers pull request automation using a fixed access token and the default services. + /// + /// The service collection. + /// The git identity used for automation commits. + /// The fixed git access token. + /// The service collection. + public static IServiceCollection AddPullRequestAutomation( + this IServiceCollection services, + AutomationIdentity identity, + string token) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(identity); + + services.TryAddSingleton( + new StaticGitAccessTokenProvider(token)); + + return services.AddPullRequestAutomation(identity); + } + + /// + /// Registers pull request automation using caller-provided services. + /// + /// The service collection. + /// The git identity used for automation commits. + /// The service collection. + /// + /// An must also be registered. A caller-provided + /// registration replaces the default . + /// + public static IServiceCollection AddPullRequestAutomation( + this IServiceCollection services, + AutomationIdentity identity) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(identity); + + services.AddLogging(); + services.TryAddSingleton(identity); + services.TryAddSingleton(); + services.TryAddSingleton(); + + return services; + } +} diff --git a/src/GitAutomation/PullRequestManager.cs b/src/GitAutomation/PullRequestManager.cs new file mode 100644 index 000000000..61bfff97d --- /dev/null +++ b/src/GitAutomation/PullRequestManager.cs @@ -0,0 +1,262 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.DotNet.GitAutomation.GitHub; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Octokit; + +namespace Microsoft.DotNet.GitAutomation; + +/// +/// Creates or updates a pull request to match a definition: clone the branch the +/// pull request is built from, apply the caller's changes, commit, then plan and +/// execute the operations needed to reconcile the pull request. +/// +public sealed class PullRequestManager +{ + private readonly IGitAccessTokenProvider _accessTokenProvider; + private readonly AutomationIdentity _identity; + private readonly Git _git; + private readonly ILogger _gitLogger; + private readonly ILoggerFactory _loggerFactory; + private readonly ILogger _logger; + + /// + /// Creates a manager with caller-provided services. + /// + /// Provides tokens for repository host operations. + /// The git identity used for the automation's commits. + /// Runs the git processes used during reconciliation. + /// Creates the loggers used to trace the reconciliation. + public PullRequestManager( + IGitAccessTokenProvider accessTokenProvider, + AutomationIdentity identity, + IProcessRunner processRunner, + ILoggerFactory loggerFactory) + { + ArgumentNullException.ThrowIfNull(accessTokenProvider); + ArgumentNullException.ThrowIfNull(identity); + ArgumentNullException.ThrowIfNull(processRunner); + ArgumentNullException.ThrowIfNull(loggerFactory); + + _accessTokenProvider = accessTokenProvider; + _identity = identity; + _loggerFactory = loggerFactory; + _logger = loggerFactory.CreateLogger(); + _gitLogger = loggerFactory.CreateLogger(nameof(Git)); + _git = new Git(processRunner, _gitLogger); + } + + /// + /// Creates a manager with a caller-provided token provider and the default + /// . + /// + /// Provides tokens for repository host operations. + /// The git identity used for the automation's commits. + /// + /// Creates the loggers used to trace the reconciliation. Omit (or pass + /// ) to disable logging. + /// + public PullRequestManager( + IGitAccessTokenProvider accessTokenProvider, + AutomationIdentity identity, + ILoggerFactory? loggerFactory = null) + : this( + accessTokenProvider, + identity, + CreateDefaultProcessRunner(loggerFactory), + loggerFactory ?? NullLoggerFactory.Instance) + { + } + + /// + /// Creates a manager with a fixed token and the default . + /// + /// An access token with permission to push and open pull requests. + /// The git identity used for the automation's commits. + /// + /// Creates the loggers used to trace the reconciliation. Omit (or pass + /// ) to disable logging. + /// + public PullRequestManager( + string token, + AutomationIdentity identity, + ILoggerFactory? loggerFactory = null) + : this( + new StaticGitAccessTokenProvider(token), + identity, + loggerFactory) + { + } + + /// + /// Creates the pull request if it does not exist, or updates it to match + /// the definition if it has drifted. + /// + /// The desired pull request state and changes. + /// The repository the pull request is opened against. + /// + /// The repository commits are pushed to. Omit (or pass ) to + /// push directly to without a fork. + /// + /// How an existing pull request branch is updated. + /// How commits from other authors are handled. + /// A token that cancels the reconciliation. + public async Task CreateOrUpdateAsync( + PullRequestDefinition definition, + GitHubRepo upstream, + GitHubRepo? fork = null, + PullRequestUpdateStrategy updateStrategy = PullRequestUpdateStrategy.Append, + ForeignCommitPolicy onForeignCommits = ForeignCommitPolicy.Proceed, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(definition); + ArgumentNullException.ThrowIfNull(upstream); + + string token = await _accessTokenProvider.GetTokenAsync(cancellationToken); + + IRepoHost host = new GitHubRepoHost( + targetRepo: upstream, + sourceRepo: fork ?? upstream, + token, + CreateClient(token), + _loggerFactory, + _git); + + _logger.LogInformation( + "Creating or updating pull request for branch '{Key}' into '{TargetBranch}'.", + definition.Key, + definition.TargetBranch); + + // Fetch the existing pull request first: it decides which branch we build on. + ExistingPullRequest? existing = await host.GetPullRequest(definition.Key, cancellationToken); + + if (existing is null) + { + _logger.LogInformation("No open pull request found for branch '{Key}'.", definition.Key); + } + else + { + _logger.LogInformation( + "Found open pull request #{Number} ({Url}) with {CommitCount} commit(s) on its branch.", + existing.Number, + existing.Url, + existing.Commits.Count); + } + + // Always build on the branch the pull request is *from* (the source branch), never + // the target branch we merge into. When an Append update targets an existing pull + // request, its source branch already has our previous commits, so cloning it lets new + // commits stack on top and the push fast-forward. Otherwise branch fresh from the + // target branch: there is nothing to stack on (no pull request), or Replace will + // overwrite the branch entirely with a force-push. + bool appendToExistingPullRequest = existing is not null && updateStrategy == PullRequestUpdateStrategy.Append; + GitHubRepo pullRequestSourceRepo = fork ?? upstream; + GitHubRepo cloneRepo = appendToExistingPullRequest ? pullRequestSourceRepo : upstream; + string cloneBranch = appendToExistingPullRequest ? definition.Key : definition.TargetBranch; + + _logger.LogInformation("Cloning {Url} branch '{Branch}'.", cloneRepo.GetCloneUrl(), cloneBranch); + + using GitWorkspace workspace = await GitWorkspace.CloneAsync( + _git, + _gitLogger, + cloneRepo.GetAuthenticatedCloneUrl(token), + cloneBranch, + _identity.AuthorName, + _identity.AuthorEmail, + cancellationToken); + + GitContext gitContext = new(workspace.WorkingDirectory, _git, _gitLogger); + + string clonedCommit = await _git.RunAsync( + secret: null, workspace.WorkingDirectory, cancellationToken, "rev-parse", "HEAD"); + + _logger.LogInformation( + "Cloned branch '{Branch}' at commit {Commit} into {Directory}.", + cloneBranch, + clonedCommit, + workspace.WorkingDirectory); + + // Capture the target branch's tree before applying changes so the Planner can + // tell whether the caller's changes actually produced a diff worth proposing. + // This only informs the no-pull-request case, where the base *is* the target branch. + string targetBranchTreeHash = await _git.RunAsync( + secret: null, + workspace.WorkingDirectory, + cancellationToken, + "rev-parse", + "HEAD^{tree}"); + + _logger.LogInformation("Applying changes."); + await definition.ApplyChanges(gitContext, cancellationToken); + await gitContext.CommitAsync(definition.Title, cancellationToken); + + string treeHash = await _git.RunAsync( + secret: null, + workspace.WorkingDirectory, + cancellationToken, + "rev-parse", + "HEAD^{tree}"); + + PullRequestState desired = new( + definition.Key, + definition.Title, + definition.Body, + definition.TargetBranch, + treeHash); + + TargetBranchState targetBranch = new(targetBranchTreeHash); + + IOperation[] operations = Planner.Plan( + workspace.WorkingDirectory, + _identity, + desired, + targetBranch, + existing, + updateStrategy, + onForeignCommits + ).ToArray(); + + if (operations.Length == 0) + { + _logger.LogInformation("Pull request already up to date; nothing to do."); + } + else + { + var operationsString = string.Join(", ", operations); + _logger.LogInformation( + "Planned {Count} operation(s) to reconcile the pull request: [ {Operations} ]", + operations.Length, + operationsString); + } + + IReadOnlyList results = await host.ExecuteAsync(operations, cancellationToken); + + // A create result carries its own URL — trust it directly. + PullRequestCreated? created = results.OfType().SingleOrDefault(); + if (created is not null) + { + return new PullRequestResult(PullRequestAction.Created, created.Url); + } + + // No pull request was created. The URL, if any, is the one the host reported + // alongside the existing pull request — null when none exists. + PullRequestAction action = results.Count > 0 ? PullRequestAction.Updated : PullRequestAction.NoChange; + return new PullRequestResult(action, existing?.Url); + } + + private static IGitHubClient CreateClient(string token) + { + var productHeaderValue = new ProductHeaderValue("Microsoft.DotNet.GitAutomation"); + var credentials = new Credentials(token); + return new GitHubClient(productHeaderValue) { Credentials = credentials }; + } + + private static IProcessRunner CreateDefaultProcessRunner(ILoggerFactory? loggerFactory) + { + ILoggerFactory effectiveLoggerFactory = loggerFactory ?? NullLoggerFactory.Instance; + return new ProcessRunner(effectiveLoggerFactory.CreateLogger()); + } +} diff --git a/src/GitAutomation/README.md b/src/GitAutomation/README.md new file mode 100644 index 000000000..6f3479a3d --- /dev/null +++ b/src/GitAutomation/README.md @@ -0,0 +1,107 @@ +# Microsoft.DotNet.GitAutomation + +`Microsoft.DotNet.GitAutomation` is a library for declaratively managing +pull requests and (eventually) issues. + +This pattern is most useful for automation that repeatedly opens or updates +similar pull requests or issues, like dependency or version updates. + +## Features + +| Feature | GitHub | Azure DevOps | +| ------- | ------ | ------------ | +| Pull requests | ✅ | - | +| Issues | - | - | +| Groups of issues | - | - | + +The feature matrix will be filled out incrementally (as needed) in order to +accomplish https://github.com/dotnet/docker-tools/issues/1658. + +## Usage + +### Open a pull request + +```csharp +using Microsoft.DotNet.GitAutomation; +using Microsoft.DotNet.GitAutomation.GitHub; + +// Instantiate the pull request manager. +var pullRequestManager = new PullRequestManager( + token: Environment.GetEnvironmentVariable("GITHUB_TOKEN") ?? "", + identity: new AutomationIdentity("bot", "bot@example.com"), + // Microsoft.Extensions.Logging support: + // loggerFactory: ... +); + +// Declare the pull request that you want to create. +var pullRequest = new PullRequestDefinition( + // `Key` is the sole method used to identify pull requests managed by this automation. + // There will never be two pull requests open against the same repo with the same key. + // To open two simultaneous pull requests against the same repo, use different keys. + // All other properties of a pull request definition are free to change between runs. + Key: "version-updates/update-generated-files", + Title: "Update dependencies", + Body: "...", + TargetBranch: "main", + ApplyChanges: async (git, ct) => + { + string generatedFile = Path.Combine(git.WorkspaceDirectory, "generated.txt"); + await File.WriteAllTextAsync(generatedFile, "...", ct); + await git.CommitAsync("Update generated.txt", ct); + } +); + +// The manager either creates a new pull request or updates the existing pull +// request if one is already open. +PullRequestResult result = await pullRequestManager.CreateOrUpdateAsync( + definition: pullRequest, + upstream: new GitHubRepo("dotnet", "example"), + + // To submit the PR from a fork: + // fork: new GitHubRepo("bot-account", "example"), + + // Update strategies: + // - Append: for an existing pull request, take the source branch and add new + // commits on top. + // - Replace: for an existing pull request, take the latest changes from the + // target branch, make commits on top, and force push to the source branch. + // + // When no pull request exists, the source branch is reset to the state of + // the target branch no matter which strategy is selected. + updateStrategy: PullRequestUpdateStrategy.Append, + + // - Proceed: ignore commits that weren't authored by this automation and + // continue with the chosen update strategy. + // - Stop: refuse to make changes to a PR if it contains commits that + // weren't authored by this automation. + onForeignCommits: ForeignCommitPolicy.Proceed, + + cancellationToken: ct +); +``` + +### Dependency injection + +Register pull request automation with a git identity and fixed token. The +resulting `PullRequestManager` can be injected into application services: + +```csharp +using Microsoft.Extensions.DependencyInjection; + +services.AddPullRequestAutomation( + identity: new AutomationIdentity("bot", "bot@example.com"), + token: Environment.GetEnvironmentVariable("GITHUB_TOKEN") ?? ""); +``` + +For more control, register each dependency directly: + +```csharp +services.AddLogging(builder => builder.AddSimpleConsole()); +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(new AutomationIdentity("bot", "bot@example.com")); +services.AddSingleton(); +``` + +`IGitAccessTokenProvider` is queried before each operation, so implementations +can refresh credentials such as GitHub App installation tokens. diff --git a/src/GitAutomation/StaticGitAccessTokenProvider.cs b/src/GitAutomation/StaticGitAccessTokenProvider.cs new file mode 100644 index 000000000..3e9f0237b --- /dev/null +++ b/src/GitAutomation/StaticGitAccessTokenProvider.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.DotNet.GitAutomation; + +/// +/// Provides a fixed access token, such as a GitHub or Azure DevOps personal access token. +/// +public sealed class StaticGitAccessTokenProvider : IGitAccessTokenProvider +{ + private readonly string _token; + + /// + /// Creates a provider for a fixed access token. + /// + /// The access token value. + public StaticGitAccessTokenProvider(string token) + { + ArgumentException.ThrowIfNullOrWhiteSpace(token); + _token = token; + } + + /// + public ValueTask GetTokenAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.FromResult(_token); + } +}