From 559c3f8caefaa5565be7ceba03168f7abb7b7d36 Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Tue, 7 Jul 2026 17:10:41 -0700 Subject: [PATCH] Add Automation library Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/instructions/csharp.instructions.md | 61 +----- Microsoft.DotNet.DockerTools.slnx | 2 + .../Microsoft.DotNet.Automation.Tests.csproj | 26 +++ .../PullRequestPlannerTests.cs | 195 +++++++++++++++++ src/Automation/Git.cs | 103 +++++++++ src/Automation/GitContext.cs | 30 +++ src/Automation/GitHub/GitHubRepo.cs | 21 ++ src/Automation/GitHub/GitHubRepoHost.cs | 207 ++++++++++++++++++ src/Automation/GitWorkspace.cs | 84 +++++++ src/Automation/IGitContext.cs | 11 + src/Automation/IRepoHost.cs | 11 + .../Microsoft.DotNet.Automation.csproj | 23 ++ src/Automation/Models.cs | 112 ++++++++++ src/Automation/Planner.cs | 82 +++++++ src/Automation/PullRequestManager.cs | 186 ++++++++++++++++ src/Automation/README.md | 1 + 16 files changed, 1101 insertions(+), 54 deletions(-) create mode 100644 src/Automation.Tests/Microsoft.DotNet.Automation.Tests.csproj create mode 100644 src/Automation.Tests/PullRequestPlannerTests.cs create mode 100644 src/Automation/Git.cs create mode 100644 src/Automation/GitContext.cs create mode 100644 src/Automation/GitHub/GitHubRepo.cs create mode 100644 src/Automation/GitHub/GitHubRepoHost.cs create mode 100644 src/Automation/GitWorkspace.cs create mode 100644 src/Automation/IGitContext.cs create mode 100644 src/Automation/IRepoHost.cs create mode 100644 src/Automation/Microsoft.DotNet.Automation.csproj create mode 100644 src/Automation/Models.cs create mode 100644 src/Automation/Planner.cs create mode 100644 src/Automation/PullRequestManager.cs create mode 100644 src/Automation/README.md diff --git a/.github/instructions/csharp.instructions.md b/.github/instructions/csharp.instructions.md index f964dfc4f..80a8aee8f 100644 --- a/.github/instructions/csharp.instructions.md +++ b/.github/instructions/csharp.instructions.md @@ -2,65 +2,18 @@ applyTo: "**/*.cs" --- -# C#/.NET Guidelines - -## Coding style - -For all new C# code: - -- Use file-scoped namespaces. -- Use collection expresions - write `[1, 2, 3]` and not `new List { 1, 2, 3 }`. -- Use explicit type declarations instead of `var` for local variables. -- Use switch expressions and pattern matching. -- Use string interpolation (`$"Hello, {name}!"`) instead of `string.Format` or concatenation. -- Use `"""triple-quoted strings"""` for multi-line string literals. These can be interpolated as well. -- Use expression-bodied members for simple getters and setters. +# C# Coding Guidelines ## Naming -- Avoid single-letter variable names, except for simple loop counters (e.g. `i`, `j`). -- Avoid abbreviations or acronyms in names, except for widely known and accepted abbreviations (e.g. `Id`, `Url`, `Http`). -- Prefer clarity over brevity - a longer descriptive name is better than a short ambiguous one. +- Use descriptive variable names. A longer descriptive name is better than a short ambiguous one. Don't abbreviate except for widely known and accepted abbreviations (e.g. `Id`, `Url`, `Http`). -## Code Design Rules +## Design -- Use immutable records instead of classes for DTOs. -- Do not default to `public` accessibility for members and classes. Follow the least-exposure rule: `private` > `internal` > `protected` > `public` -- Do not add unused methods/parameters for use cases that were not asked for. -- Reuse existing methods or services as much as possible. -- Use composition over inheritance. +- Class design: Use composition instead of inheritance. Instead of reaching for class hierarchies, abstract classes, and protected or internal methods, think about how you could accomplish the same task using plain data types, interfaces, and extension methods instead. +- Data type design: Use records or readonly record structs for data types. Use primary constructors and put functionality in extension methods instead of instance methods. - Use LINQ instead of for loops when working with collections. -- Place private nested types at the end of the containing class. - -## Error Handling & Edge Cases - -- Guard early; use `string.IsNullOrWhiteSpace`, `ArgumentNullException.ThrowIfNull`, or `ArgumentNullException.ThrowIfNullOrWhiteSpace`. -- Avoid using null for control flow. -- In methods that return collections, return empty collections instead of null. -- The null-forgiving operator (`!`) is **always** a code smell. -- Do not add excessive or unnecessary try/catch blocks within the same assembly. - -## Async Best Practices - -- All async methods must have names ending with `Async`. -- Always await async methods - do not "fire and forget". -- Always accept and pass along a `CancellationToken` in async code. -- Don’t add `async/await` if you can simply return a Task directly. - -## Testing - -- Use Shouldly when writing new assertions. -- Use clear assertions that verify the outcome expressed by the test name. -- Tests should be able to run in any order or in parallel. -- Use or add helper methods for constructing mocks and complex test data objects. -- Avoid disk I/O if possible; use in-memory alternatives or mocks. -- Do not add "Arrange-Act-Assert" comments. -## Comments +## Tests -- Add XML documentation comments for new **public** or **internal** types and members. -- Primary documentation belongs on interfaces, implementations should use `` unless additional context is needed. -- Comments that simply restate the member or parameter name do not provide value. -- Comments should provide additional context or explain non-obvious behavior, especially for parameters. -- Comments inside methods should explain "why," not "what". -- Avoid redundant comments that restate the code - not all code needs comments. +- Don't add `InternalsVisibleTo` for tests. diff --git a/Microsoft.DotNet.DockerTools.slnx b/Microsoft.DotNet.DockerTools.slnx index 0c8d96696..3e85015bc 100644 --- a/Microsoft.DotNet.DockerTools.slnx +++ b/Microsoft.DotNet.DockerTools.slnx @@ -7,6 +7,8 @@ + + diff --git a/src/Automation.Tests/Microsoft.DotNet.Automation.Tests.csproj b/src/Automation.Tests/Microsoft.DotNet.Automation.Tests.csproj new file mode 100644 index 000000000..ae879a7c1 --- /dev/null +++ b/src/Automation.Tests/Microsoft.DotNet.Automation.Tests.csproj @@ -0,0 +1,26 @@ + + + + net9.0 + false + true + true + enable + enable + + + + true + false + MSTest + + + + + + + + + + + diff --git a/src/Automation.Tests/PullRequestPlannerTests.cs b/src/Automation.Tests/PullRequestPlannerTests.cs new file mode 100644 index 000000000..f022496e8 --- /dev/null +++ b/src/Automation.Tests/PullRequestPlannerTests.cs @@ -0,0 +1,195 @@ +// 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.Runtime.Intrinsics.Arm; +using CsCheck; +using Octokit; + +namespace Microsoft.DotNet.Automation.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); + }; + + [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); + } + + // 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()); + } + + // create operation is only ever produced when no pull request exists. + [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/Automation/Git.cs b/src/Automation/Git.cs new file mode 100644 index 000000000..517d0ec17 --- /dev/null +++ b/src/Automation/Git.cs @@ -0,0 +1,103 @@ +// 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.Automation; + +internal static class Git +{ + public static async Task RunAsync( + ILogger logger, + string? secret, + string? workingDirectory, + CancellationToken cancellationToken, + params string[] args) + { + ProcessStartInfo startInfo = new("git") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + if (workingDirectory is not null) + { + startInfo.WorkingDirectory = workingDirectory; + } + + foreach (string arg in args) + { + startInfo.ArgumentList.Add(arg); + } + + logger.LogDebug("Running: git {Args}", Redact(string.Join(' ', args), secret)); + + using Process process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Failed to start git process."); + + 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 ex) + { + // Best effort; the process may have already exited. + logger.LogWarning(ex, "Failed to kill git process after cancellation."); + } + throw; + } + + string output = await outputTask; + string error = await errorTask; + + // 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 (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"Git command failed with exit code {process.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/Automation/GitContext.cs b/src/Automation/GitContext.cs new file mode 100644 index 000000000..47453f235 --- /dev/null +++ b/src/Automation/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.Automation; + +internal sealed class GitContext(string workspaceDirectory, ILogger logger) : IGitContext +{ + public string WorkspaceDirectory { get; } = workspaceDirectory; + + public async Task CommitAsync(string message, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(message); + + string status = await Git.RunAsync(logger, secret: null, WorkspaceDirectory, cancellationToken, "status", "--porcelain"); + if (string.IsNullOrWhiteSpace(status)) + { + logger.LogInformation("No changes to commit; working tree is clean."); + return; + } + + await Git.RunAsync(logger, secret: null, WorkspaceDirectory, cancellationToken, "add", "--all"); + await Git.RunAsync(logger, secret: null, WorkspaceDirectory, cancellationToken, "commit", "--message", message); + + string commit = await Git.RunAsync(logger, secret: null, WorkspaceDirectory, cancellationToken, "rev-parse", "HEAD"); + logger.LogInformation("Committed changes as {Commit}: \"{Message}\".", commit, message); + } +} diff --git a/src/Automation/GitHub/GitHubRepo.cs b/src/Automation/GitHub/GitHubRepo.cs new file mode 100644 index 000000000..e668b5a7d --- /dev/null +++ b/src/Automation/GitHub/GitHubRepo.cs @@ -0,0 +1,21 @@ +// 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.Automation.GitHub; + +public sealed record GitHubRepo(string Owner, string Name); + +public 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/Automation/GitHub/GitHubRepoHost.cs b/src/Automation/GitHub/GitHubRepoHost.cs new file mode 100644 index 000000000..e2b9a9588 --- /dev/null +++ b/src/Automation/GitHub/GitHubRepoHost.cs @@ -0,0 +1,207 @@ +// 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.Automation.GitHub; + +internal sealed class GitHubRepoHost( + GitHubRepo targetRepo, + GitHubRepo sourceRepo, + string token, + IGitHubClient client, + ILoggerFactory loggerFactory +) : IRepoHost +{ + private readonly ILogger _logger = loggerFactory.CreateLogger(); + + private readonly ILogger _gitLogger = loggerFactory.CreateLogger("Microsoft.DotNet.Automation.Git"); + + 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(_gitLogger, 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(_gitLogger, secret: null, dir, cancellationToken, "rev-parse", "HEAD"); + + bool forcePush = existingLine is not null && operation.Strategy == PullRequestUpdateStrategy.Replace; + 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(_gitLogger, 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/Automation/GitWorkspace.cs b/src/Automation/GitWorkspace.cs new file mode 100644 index 000000000..de655ce18 --- /dev/null +++ b/src/Automation/GitWorkspace.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. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Automation; + +internal sealed class GitWorkspace(string directory, ILogger logger) : IDisposable +{ + public string WorkingDirectory { get; } = directory; + + public static async Task CloneAsync( + 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 git = async (string[] args, string? directory = null) => + await Git.RunAsync(logger, secret, directory, ct, args); + + try + { + await git([ + "clone", + "--filter=blob:none", + "--single-branch", + "--no-tags", + "--branch", + branch, + cloneUrl.AbsoluteUri, + directory, + ]); + + await git(["config", "user.name", authorName], directory); + await git(["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/Automation/IGitContext.cs b/src/Automation/IGitContext.cs new file mode 100644 index 000000000..dc006f05c --- /dev/null +++ b/src/Automation/IGitContext.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.Automation; + +public interface IGitContext +{ + string WorkspaceDirectory { get; } + Task CommitAsync(string message, CancellationToken cancellationToken); +} diff --git a/src/Automation/IRepoHost.cs b/src/Automation/IRepoHost.cs new file mode 100644 index 000000000..88599e12f --- /dev/null +++ b/src/Automation/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.Automation; + +internal interface IRepoHost +{ + Task GetPullRequest(string key, CancellationToken cancellationToken); + Task> ExecuteAsync(IEnumerable operations, CancellationToken cancellationToken); +} diff --git a/src/Automation/Microsoft.DotNet.Automation.csproj b/src/Automation/Microsoft.DotNet.Automation.csproj new file mode 100644 index 000000000..4f1410e43 --- /dev/null +++ b/src/Automation/Microsoft.DotNet.Automation.csproj @@ -0,0 +1,23 @@ + + + + net9.0 + enable + true + true + enable + + + + true + Declarative git automation library. + + false + + + + + + + + diff --git a/src/Automation/Models.cs b/src/Automation/Models.cs new file mode 100644 index 000000000..c9639893d --- /dev/null +++ b/src/Automation/Models.cs @@ -0,0 +1,112 @@ +// 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.Automation; + +public sealed record PullRequestDefinition( + string Key, + string Title, + string Body, + string TargetBranch, + Func ApplyChanges); + +public sealed record PullRequestState(string Key, string Title, string Body, string TargetBranch, string TreeHash); + +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, +} + +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. +/// +public 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. +/// +public sealed record TargetBranchState(string TreeHash); + +/// +/// The automation's git identity, used to distinguish its own commits from foreign ones. +/// +public sealed record AutomationIdentity(string AuthorName, string AuthorEmail); + +/// +/// A single commit observed on an existing pull request's branch. +/// +public sealed record CommitInfo(string Sha, string AuthorName, string AuthorEmail); + +// TODO: Use C# 15 unions after .NET 11's release +public interface IOperation; +public sealed record PushCommitsOperation(string WorkspaceDirectory, string SourceBranch, PullRequestUpdateStrategy Strategy) : IOperation; +public sealed record CreatePullRequestOperation(string Title, string Body, string SourceBranch, string TargetBranch) : IOperation; +public sealed record UpdateTitleOperation(int Number, string Title) : IOperation; +public sealed record UpdateBodyOperation(int Number, string Body) : IOperation; +public sealed record UpdateBaseBranchOperation(int Number, string TargetBranch) : IOperation; + +// TODO: Use C# 15 unions after .NET 11's release +public interface IOperationResult; +public sealed record CommitsPushed(string Branch, string FromSha, string ToSha, Uri Url) : IOperationResult; +public sealed record PullRequestCreated(int Number, Uri Url) : IOperationResult; +public sealed record TitleUpdated(int Number, string Title) : IOperationResult; +public sealed record BodyUpdated(int Number, string Body) : IOperationResult; +public sealed record BaseBranchUpdated(int Number, string TargetBranch) : IOperationResult; diff --git a/src/Automation/Planner.cs b/src/Automation/Planner.cs new file mode 100644 index 000000000..b758070f3 --- /dev/null +++ b/src/Automation/Planner.cs @@ -0,0 +1,82 @@ +// 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.Automation; + +public 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) + { + operations.Add(new PushCommitsOperation( + workspaceDirectory, + desiredState.Key, + updateStrategy)); + } + + 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/Automation/PullRequestManager.cs b/src/Automation/PullRequestManager.cs new file mode 100644 index 000000000..83f627f7e --- /dev/null +++ b/src/Automation/PullRequestManager.cs @@ -0,0 +1,186 @@ +// 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.Automation.GitHub; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Octokit; + +namespace Microsoft.DotNet.Automation; + +/// +/// 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. +/// +/// A GitHub token with permission to push and open pull requests. +/// The repository the pull request is opened against. +/// The git identity used for the automation's commits. +/// +/// The repository commits are pushed to. Omit (or pass ) to +/// push directly to without a fork. +/// +/// +/// Creates the loggers used to trace the reconciliation. Omit (or pass +/// ) to disable logging. +/// +public sealed class PullRequestManager( + string token, + GitHubRepo upstream, + AutomationIdentity identity, + GitHubRepo? fork = null, + ILoggerFactory? loggerFactory = null) +{ + private readonly ILogger _logger = + (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + + private readonly ILogger _gitLogger = + (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(nameof(Git)); + + private readonly IRepoHost _host = new GitHubRepoHost( + targetRepo: upstream, + sourceRepo: fork ?? upstream, + token, + CreateClient(token), + loggerFactory ?? NullLoggerFactory.Instance); + + /// + /// Creates the pull request if it does not exist, or updates it to match + /// the definition if it has drifted. + /// + public async Task CreateOrUpdateAsync( + PullRequestDefinition definition, + PullRequestUpdateStrategy updateStrategy = PullRequestUpdateStrategy.Append, + ForeignCommitPolicy onForeignCommits = ForeignCommitPolicy.Proceed, + CancellationToken cancellationToken = default) + { + _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 stackOnExistingBranch = + existing is not null && updateStrategy == PullRequestUpdateStrategy.Append; + string cloneBranch = stackOnExistingBranch ? definition.Key : definition.TargetBranch; + + _logger.LogInformation("Cloning {Url} branch '{Branch}'.", upstream.GetCloneUrl(), cloneBranch); + + using GitWorkspace workspace = await GitWorkspace.CloneAsync( + _gitLogger, + upstream.GetAuthenticatedCloneUrl(token), + cloneBranch, + identity.AuthorName, + identity.AuthorEmail, + cancellationToken); + + GitContext gitContext = new(workspace.WorkingDirectory, _gitLogger); + + string clonedCommit = await Git.RunAsync( + _gitLogger, 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( + _gitLogger, + 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( + _gitLogger, + 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.Automation"); + var credentials = new Credentials(token); + return new GitHubClient(productHeaderValue) { Credentials = credentials }; + } +} diff --git a/src/Automation/README.md b/src/Automation/README.md new file mode 100644 index 000000000..6df908c64 --- /dev/null +++ b/src/Automation/README.md @@ -0,0 +1 @@ +# Microsoft.DotNet.Automation