From 4272a5c6e07643375d50a4272d4d8baae42c9cf6 Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Mon, 22 Jun 2026 11:01:07 -0700 Subject: [PATCH 1/3] Add Microsoft.DotNet.Automation library Introduce the Microsoft.DotNet.Automation library for automating git commits and pull requests against GitHub. Provides a RepoHostEngine and supporting types for branch/PR automation, replacing the previous ImageBuilder.Automation implementation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Microsoft.DotNet.DockerTools.slnx | 1 + src/Automation/BranchOutcome.cs | 24 ++ src/Automation/BranchResult.cs | 18 ++ src/Automation/BranchSpec.cs | 21 ++ src/Automation/ForeignCommitPolicy.cs | 31 ++ src/Automation/GitAuthor.cs | 12 + src/Automation/GitAutomationOptions.cs | 24 ++ src/Automation/GitCli.cs | 84 ++++++ src/Automation/GitCliResult.cs | 23 ++ src/Automation/GitCommit.cs | 29 ++ src/Automation/GitContext.cs | 47 +++ src/Automation/GitException.cs | 21 ++ src/Automation/GitHub/GitHubPullRequestApi.cs | 74 +++++ src/Automation/GitHub/GitHubRepo.cs | 20 ++ src/Automation/GitHub/GitHubRepoHost.cs | 71 +++++ src/Automation/GitWorkspace.cs | 196 ++++++++++++ src/Automation/IGitContext.cs | 24 ++ src/Automation/IPullRequestApi.cs | 29 ++ src/Automation/IRepoHost.cs | 36 +++ .../Microsoft.DotNet.Automation.csproj | 25 ++ src/Automation/PullRequestInfo.cs | 11 + src/Automation/PullRequestOutcome.cs | 37 +++ src/Automation/PullRequestResult.cs | 27 ++ src/Automation/PullRequestSpec.cs | 58 ++++ src/Automation/PullRequestUpdateStrategy.cs | 27 ++ src/Automation/README.md | 113 +++++++ src/Automation/RemoteRepo.cs | 22 ++ src/Automation/RepoHostEngine.cs | 280 ++++++++++++++++++ 28 files changed, 1385 insertions(+) create mode 100644 src/Automation/BranchOutcome.cs create mode 100644 src/Automation/BranchResult.cs create mode 100644 src/Automation/BranchSpec.cs create mode 100644 src/Automation/ForeignCommitPolicy.cs create mode 100644 src/Automation/GitAuthor.cs create mode 100644 src/Automation/GitAutomationOptions.cs create mode 100644 src/Automation/GitCli.cs create mode 100644 src/Automation/GitCliResult.cs create mode 100644 src/Automation/GitCommit.cs create mode 100644 src/Automation/GitContext.cs create mode 100644 src/Automation/GitException.cs create mode 100644 src/Automation/GitHub/GitHubPullRequestApi.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/IPullRequestApi.cs create mode 100644 src/Automation/IRepoHost.cs create mode 100644 src/Automation/Microsoft.DotNet.Automation.csproj create mode 100644 src/Automation/PullRequestInfo.cs create mode 100644 src/Automation/PullRequestOutcome.cs create mode 100644 src/Automation/PullRequestResult.cs create mode 100644 src/Automation/PullRequestSpec.cs create mode 100644 src/Automation/PullRequestUpdateStrategy.cs create mode 100644 src/Automation/README.md create mode 100644 src/Automation/RemoteRepo.cs create mode 100644 src/Automation/RepoHostEngine.cs diff --git a/Microsoft.DotNet.DockerTools.slnx b/Microsoft.DotNet.DockerTools.slnx index 0c8d96696..7e3ead030 100644 --- a/Microsoft.DotNet.DockerTools.slnx +++ b/Microsoft.DotNet.DockerTools.slnx @@ -7,6 +7,7 @@ + diff --git a/src/Automation/BranchOutcome.cs b/src/Automation/BranchOutcome.cs new file mode 100644 index 000000000..9669f38bb --- /dev/null +++ b/src/Automation/BranchOutcome.cs @@ -0,0 +1,24 @@ +// 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; + +/// +/// What an operation did. +/// +public enum BranchOutcome +{ + /// + /// The branch already contained the desired changes; nothing was pushed. + /// + Unchanged, + + /// New commits were pushed to the branch. + Updated, + + /// + /// This was a dry run: there were changes, but nothing was pushed. + /// + DryRun, +} diff --git a/src/Automation/BranchResult.cs b/src/Automation/BranchResult.cs new file mode 100644 index 000000000..dfe9aa936 --- /dev/null +++ b/src/Automation/BranchResult.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.Automation; + +/// +/// The result of an operation: +/// what (if anything) had to change to reach the desired state. +/// +public sealed record BranchResult +{ + /// What the operation did. + public required BranchOutcome Outcome { get; init; } + + /// The commits the automation pushed, in creation order. + public IReadOnlyList Commits { get; init; } = []; +} diff --git a/src/Automation/BranchSpec.cs b/src/Automation/BranchSpec.cs new file mode 100644 index 000000000..3687180a0 --- /dev/null +++ b/src/Automation/BranchSpec.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; + +/// +/// The desired state of a branch: its current tip with the given changes +/// applied on top, committed directly to the branch (no pull request). +/// +public sealed record BranchSpec +{ + /// The branch to commit to. + public required string Branch { get; init; } + + /// + /// Applies the desired changes to a local clone of the repo and creates + /// commits through the supplied . + /// + public required Func Apply { get; init; } +} diff --git a/src/Automation/ForeignCommitPolicy.cs b/src/Automation/ForeignCommitPolicy.cs new file mode 100644 index 000000000..92bf4c14e --- /dev/null +++ b/src/Automation/ForeignCommitPolicy.cs @@ -0,0 +1,31 @@ +// 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; + +/// +/// What to do when a pull request's branch contains commits from someone +/// other than the automation. Detection is mechanical (commits whose author +/// differs from ); the decision is +/// the caller's. +/// +public enum ForeignCommitPolicy +{ + /// + /// Post a comment on the pull request explaining why the update was + /// skipped (including , if set) + /// and stop without modifying the branch. The safe default: the + /// automation never destroys another actor's work. + /// + CommentAndStop, + + /// + /// Proceed as if the foreign commits were the automation's own, applying + /// normally. With + /// the foreign commits are + /// kept and the update lands on top; only + /// discards them. + /// + Proceed, +} diff --git a/src/Automation/GitAuthor.cs b/src/Automation/GitAuthor.cs new file mode 100644 index 000000000..070feccaf --- /dev/null +++ b/src/Automation/GitAuthor.cs @@ -0,0 +1,12 @@ +// 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; + +/// +/// The identity used for git commits. +/// +/// The committer's name (e.g. "dotnet-docker-bot"). +/// The committer's email address. +public sealed record GitAuthor(string Name, string Email); diff --git a/src/Automation/GitAutomationOptions.cs b/src/Automation/GitAutomationOptions.cs new file mode 100644 index 000000000..627abdddd --- /dev/null +++ b/src/Automation/GitAutomationOptions.cs @@ -0,0 +1,24 @@ +// 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; + +/// +/// Common settings for all automation operations. +/// +/// +/// The token used to authenticate with the remote service, for both git and +/// REST API operations. May be empty for repositories that allow anonymous +/// read access when no write operations will be performed (e.g. dry runs). +/// +/// The author/committer identity used for commits. +/// +/// When true, all local operations (cloning, applying changes, diffing) are +/// performed and logged, but nothing is pushed and no pull requests are +/// created or updated. +/// +public sealed record GitAutomationOptions( + string Token, + GitAuthor Author, + bool IsDryRun = false); diff --git a/src/Automation/GitCli.cs b/src/Automation/GitCli.cs new file mode 100644 index 000000000..728765d34 --- /dev/null +++ b/src/Automation/GitCli.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 System.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Automation; + +/// +/// Executes git commands, masking the auth token in logs and error messages. +/// +internal sealed class GitCli(ILogger logger, string? secret) +{ + private const string SecretMask = "***"; + + private readonly ILogger _logger = logger; + + // The secret can appear in both raw form and percent-escaped form (when embedded in a URL). + private readonly string[] _secrets = string.IsNullOrEmpty(secret) + ? [] + : [secret, Uri.EscapeDataString(secret)]; + + /// + /// Runs a git command and returns its result. The standard output is + /// returned verbatim; call when + /// trailing newlines should be stripped. Throws + /// on failure. + /// + public async Task RunAsync(string? workingDirectory, params string[] args) + { + (int exitCode, string output, string error) = await ExecuteAsync(workingDirectory, args); + if (exitCode != 0) + { + throw new GitException(GetMaskedCommand(args), exitCode, Mask(error)); + } + + return new GitCliResult(output); + } + + private async Task<(int ExitCode, string Output, string Error)> ExecuteAsync( + string? workingDirectory, 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.LogInformation("Running '{Command}'", GetMaskedCommand(args)); + + using Process process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Failed to start git process."); + + Task outputTask = process.StandardOutput.ReadToEndAsync(); + Task errorTask = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + + return (process.ExitCode, await outputTask, await errorTask); + } + + private string GetMaskedCommand(string[] args) => Mask($"git {string.Join(' ', args)}"); + + private string Mask(string text) + { + foreach (string secret in _secrets.Distinct()) + { + text = text.Replace(secret, SecretMask); + } + + return text; + } +} diff --git a/src/Automation/GitCliResult.cs b/src/Automation/GitCliResult.cs new file mode 100644 index 000000000..4fb2d2873 --- /dev/null +++ b/src/Automation/GitCliResult.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.Automation; + +/// +/// The result of running a git command via . +/// +/// The command's standard output, verbatim. +internal readonly record struct GitCliResult(string StandardOutput); + +internal static class GitCliResultExtensions +{ + /// + /// Returns the command's standard output with trailing carriage returns and + /// newlines removed. Use this for commands whose output is a single value + /// (such as a SHA or branch name), where the trailing newline git appends is + /// not part of the content. + /// + public static string Trim(this GitCliResult result) => + result.StandardOutput.TrimEnd('\r', '\n'); +} diff --git a/src/Automation/GitCommit.cs b/src/Automation/GitCommit.cs new file mode 100644 index 000000000..03090f064 --- /dev/null +++ b/src/Automation/GitCommit.cs @@ -0,0 +1,29 @@ +// 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; + +/// +/// A git commit: its SHA, author, and message. +/// +/// The full commit SHA. +/// The name of the commit's author. +/// The email address of the commit's author. +/// +/// The commit message. For commits read back from history (e.g. during +/// foreign-commit detection) this is only the subject line; for commits the +/// automation creates it is the full message that was passed to +/// . +/// +public sealed record GitCommit(string Sha, string AuthorName, string AuthorEmail, string Message); + +public static class GitCommitExtensions +{ + /// + /// The subject of the commit: the first line of its + /// . + /// + public static string Subject(this GitCommit commit) => + commit.Message.Split('\n', 2)[0].TrimEnd('\r'); +} diff --git a/src/Automation/GitContext.cs b/src/Automation/GitContext.cs new file mode 100644 index 000000000..2b1c6b8d8 --- /dev/null +++ b/src/Automation/GitContext.cs @@ -0,0 +1,47 @@ +// 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; + +/// +/// Default implementation backed by a temporary git +/// workspace. +/// +internal sealed class GitContext(GitWorkspace workspace, GitAuthor author, ILogger logger) : IGitContext +{ + private readonly List _commits = []; + + public string Directory => workspace.Path; + + public IReadOnlyList Commits => _commits; + + public async Task CommitAsync(string message, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(message); + cancellationToken.ThrowIfCancellationRequested(); + + if (!await workspace.HasChangesAsync()) + { + logger.LogInformation("Skipped commit '{Message}' because there were no changes.", message); + return null; + } + + await workspace.LogChangesAsync(); + string commitSha = await workspace.CommitAllAsync(message); + GitCommit commit = new(commitSha, author.Name, author.Email, message); + _commits.Add(commit); + return commit; + } + + public async Task ThrowIfPendingChangesAsync() + { + if (await workspace.HasChangesAsync()) + { + throw new InvalidOperationException( + "The automation callback left uncommitted changes. Call IGitContext.CommitAsync after editing files."); + } + } +} diff --git a/src/Automation/GitException.cs b/src/Automation/GitException.cs new file mode 100644 index 000000000..7e6fff5a6 --- /dev/null +++ b/src/Automation/GitException.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; + +/// +/// Thrown when a git command fails. Credentials are masked in all properties. +/// +public sealed class GitException(string command, int exitCode, string standardError) + : Exception($"'{command}' failed with exit code {exitCode}:{Environment.NewLine}{standardError}") +{ + /// + /// The git command that failed, e.g. "git push origin main". + /// + public string Command { get; } = command; + + public int ExitCode { get; } = exitCode; + + public string StandardError { get; } = standardError; +} diff --git a/src/Automation/GitHub/GitHubPullRequestApi.cs b/src/Automation/GitHub/GitHubPullRequestApi.cs new file mode 100644 index 000000000..dfc6d952f --- /dev/null +++ b/src/Automation/GitHub/GitHubPullRequestApi.cs @@ -0,0 +1,74 @@ +// 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 Octokit; + +namespace Microsoft.DotNet.Automation.GitHub; + +/// +/// Pull request operations backed by the GitHub REST API. The Octokit client +/// does not accept cancellation tokens, so they are ignored here. +/// +internal sealed class GitHubPullRequestApi( + IGitHubClient client, + GitHubRepo targetRepo, + GitHubRepo headRepo) : IPullRequestApi +{ + public async Task FindOpenAsync( + string headBranch, string targetBranch, CancellationToken cancellationToken) + { + IReadOnlyList pullRequests = await client.PullRequest.GetAllForRepository( + targetRepo.Owner, + targetRepo.Name, + new PullRequestRequest + { + Head = GetHeadRef(headBranch), + Base = targetBranch, + State = ItemStateFilter.Open, + }); + + return pullRequests.Count == 0 ? null : ToInfo(pullRequests[0]); + } + + public async Task CreateAsync( + string title, string body, string headBranch, string targetBranch, CancellationToken cancellationToken) + { + PullRequest pullRequest = await client.PullRequest.Create( + targetRepo.Owner, + targetRepo.Name, + new NewPullRequest(title, GetHeadRef(headBranch), targetBranch) + { + Body = body, + }); + + return ToInfo(pullRequest); + } + + public Task UpdateAsync(long id, string title, string body, CancellationToken cancellationToken) => + client.PullRequest.Update( + targetRepo.Owner, + targetRepo.Name, + (int)id, + new PullRequestUpdate + { + Title = title, + Body = body, + }); + + public async Task> GetCommentsAsync(long id, CancellationToken cancellationToken) + { + IReadOnlyList comments = + await client.Issue.Comment.GetAllForIssue(targetRepo.Owner, targetRepo.Name, (int)id); + return [.. comments.Select(comment => comment.Body)]; + } + + public Task AddCommentAsync(long id, string comment, CancellationToken cancellationToken) => + client.Issue.Comment.Create(targetRepo.Owner, targetRepo.Name, (int)id, comment); + + // GitHub identifies pull request head branches as "owner:branch". + private string GetHeadRef(string headBranch) => $"{headRepo.Owner}:{headBranch}"; + + private static PullRequestInfo ToInfo(PullRequest pullRequest) => + new(pullRequest.Number, pullRequest.HtmlUrl, pullRequest.Title, pullRequest.Body ?? string.Empty); +} diff --git a/src/Automation/GitHub/GitHubRepo.cs b/src/Automation/GitHub/GitHubRepo.cs new file mode 100644 index 000000000..1ca389f77 --- /dev/null +++ b/src/Automation/GitHub/GitHubRepo.cs @@ -0,0 +1,20 @@ +// 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; + +/// +/// A repository hosted on GitHub. +/// +/// The user or organization that owns the repository (e.g. "dotnet"). +/// The name of the repository (e.g. "dotnet-docker"). +public sealed record GitHubRepo(string Owner, string Name) : RemoteRepo +{ + public override Uri CloneUrl => new($"https://github.com/{Owner}/{Name}"); + + internal override Uri GetAuthenticatedCloneUrl(string token) => + string.IsNullOrEmpty(token) + ? CloneUrl + : new Uri($"https://x-access-token:{Uri.EscapeDataString(token)}@github.com/{Owner}/{Name}"); +} diff --git a/src/Automation/GitHub/GitHubRepoHost.cs b/src/Automation/GitHub/GitHubRepoHost.cs new file mode 100644 index 000000000..3d2f86868 --- /dev/null +++ b/src/Automation/GitHub/GitHubRepoHost.cs @@ -0,0 +1,71 @@ +// 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 Microsoft.Extensions.Logging.Abstractions; +using Octokit; + +namespace Microsoft.DotNet.Automation.GitHub; + +/// +/// An for repositories hosted on GitHub. Branches are +/// pushed with the git CLI; the GitHub API is only used to manage pull +/// requests and comments. +/// +public sealed class GitHubRepoHost : IRepoHost +{ + private readonly RepoHostEngine _engine; + + /// The repository that pull requests merge into and branches are pushed to. + /// Common automation settings. + /// + /// The repository that pull request head branches are pushed to. Specify a + /// fork here to avoid pushing branches to itself. + /// + public GitHubRepoHost( + GitHubRepo repo, + GitAutomationOptions options, + GitHubRepo? headRepo = null, + ILoggerFactory? loggerFactory = null) + : this(repo, options, headRepo, loggerFactory, CreateGitHubClient(options.Token)) + { + } + + internal GitHubRepoHost( + GitHubRepo repo, + GitAutomationOptions options, + GitHubRepo? headRepo, + ILoggerFactory? loggerFactory, + IGitHubClient gitHubClient) + { + headRepo ??= repo; + ILogger logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + _engine = new RepoHostEngine( + repo, + headRepo, + new GitHubPullRequestApi(gitHubClient, repo, headRepo), + options, + logger); + } + + /// + public Task EnsurePullRequestAsync(PullRequestSpec spec, CancellationToken cancellationToken = default) => + _engine.EnsurePullRequestAsync(spec, cancellationToken); + + /// + public Task EnsureBranchContentAsync(BranchSpec spec, CancellationToken cancellationToken = default) => + _engine.EnsureBranchContentAsync(spec, cancellationToken); + + private static IGitHubClient CreateGitHubClient(string token) + { + GitHubClient client = new(new ProductHeaderValue("Microsoft.DotNet.Automation")); + if (!string.IsNullOrEmpty(token)) + { + client.Credentials = new Credentials(token); + } + + return client; + } + +} diff --git a/src/Automation/GitWorkspace.cs b/src/Automation/GitWorkspace.cs new file mode 100644 index 000000000..a52f65d88 --- /dev/null +++ b/src/Automation/GitWorkspace.cs @@ -0,0 +1,196 @@ +// 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; + +/// +/// A temporary local clone of a remote repository. Deleted on dispose. +/// +internal sealed class GitWorkspace : IDisposable +{ + private readonly GitCli _git; + private readonly ILogger _logger; + + private GitWorkspace(string path, GitCli git, ILogger logger) + { + Path = path; + _git = git; + _logger = logger; + } + + /// + /// The root directory of the clone's working tree. + /// + public string Path { get; } + + /// + /// Clones a single branch of a repository into a temporary directory. + /// The clone excludes blobs that aren't needed for the checkout + /// (--filter=blob:none) to keep it fast on repositories with large + /// histories, while still having the full commit history needed to push + /// from the clone. + /// + public static async Task CloneAsync( + Uri cloneUrl, string branch, GitAuthor author, GitCli git, ILogger logger) + { + string path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + $"git-workspace-{System.IO.Path.GetRandomFileName()}"); + + await git.RunAsync( + workingDirectory: null, + "clone", + "--filter=blob:none", + "--single-branch", + "--no-tags", + "--branch", branch, + cloneUrl.AbsoluteUri, + path); + + await git.RunAsync(path, "config", "user.name", author.Name); + await git.RunAsync(path, "config", "user.email", author.Email); + + return new GitWorkspace(path, git, logger); + } + + /// + /// Creates or resets a branch at (the + /// current commit when null) and checks it out. + /// + public Task CheckoutNewBranchAsync(string branch, string? startPoint = null) => + _git.RunAsync( + Path, + startPoint is null ? ["checkout", "-B", branch] : ["checkout", "-B", branch, startPoint]); + + /// + /// Returns whether a branch exists on a remote repository. Throws + /// if the remote cannot be reached. + /// + public async Task RemoteBranchExistsAsync(Uri remoteUrl, string branch) => + !string.IsNullOrWhiteSpace( + (await _git.RunAsync(Path, "ls-remote", remoteUrl.AbsoluteUri, $"refs/heads/{branch}")).Trim()); + + /// + /// Fetches a branch from a remote repository into FETCH_HEAD. + /// + public Task FetchAsync(Uri remoteUrl, string branch) => + _git.RunAsync(Path, "fetch", remoteUrl.AbsoluteUri, branch); + + /// + /// Returns whether the working tree has any changes (including untracked files). + /// + public async Task HasChangesAsync() => + !string.IsNullOrWhiteSpace((await _git.RunAsync(Path, "status", "--porcelain")).Trim()); + + /// + /// Logs a summary of the working tree changes, and the full diff at debug level. + /// + public async Task LogChangesAsync() + { + string status = (await _git.RunAsync(Path, "status", "--porcelain")).Trim(); + _logger.LogInformation("Working tree changes:{NewLine}{Status}", Environment.NewLine, EscapeVsoDirectives(status)); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + // Stage everything so that new files show up in the diff. + await _git.RunAsync(Path, "add", "--all"); + string diff = (await _git.RunAsync(Path, "diff", "--cached")).Trim(); + _logger.LogDebug("Full diff:{NewLine}{Diff}", Environment.NewLine, EscapeVsoDirectives(diff)); + } + } + + /// + /// Stages and commits all working tree changes. Returns the new commit's SHA. + /// + public async Task CommitAllAsync(string message) + { + await _git.RunAsync(Path, "add", "--all"); + await _git.RunAsync(Path, "commit", "--message", message); + return (await _git.RunAsync(Path, "rev-parse", "HEAD")).Trim(); + } + + /// + /// Resolves a git revision (e.g. "HEAD^{tree}") to an object SHA. + /// + public async Task RevParseAsync(string revision) => + (await _git.RunAsync(Path, "rev-parse", revision)).Trim(); + + /// + /// Lists the commits reachable from but not from + /// , most recent first. + /// + public async Task> GetCommitsAsync(string tip, string excludeReachableFrom) + { + const char FieldSeparator = '\x1f'; + string output = (await _git.RunAsync( + Path, "log", "--format=%H%x1f%an%x1f%ae%x1f%s", tip, "--not", excludeReachableFrom)).Trim(); + + if (string.IsNullOrEmpty(output)) + { + return []; + } + + return + [ + .. output.Split('\n').Select(line => + { + string[] fields = line.Split(FieldSeparator); + return new GitCommit(fields[0], fields[1], fields[2], fields[3]); + }), + ]; + } + + /// + /// Pushes HEAD to the given branch on a remote repository. Without + /// , the push only succeeds if it is a + /// fast-forward. + /// + public Task PushAsync(Uri remoteUrl, string branch, bool force = false) + { + List args = ["push"]; + if (force) + { + args.Add("--force"); + } + + args.AddRange([remoteUrl.AbsoluteUri, $"HEAD:refs/heads/{branch}"]); + return _git.RunAsync(Path, [.. args]); + } + + public void Dispose() + { + try + { + DeleteDirectory(new DirectoryInfo(Path)); + } + catch (DirectoryNotFoundException) + { + } + catch (Exception e) + { + _logger.LogWarning("Failed to delete temporary directory {Path}: {Message}", Path, e.Message); + } + } + + private static void DeleteDirectory(DirectoryInfo directory) + { + // Files under .git are read-only and must be made writable before deletion on Windows. + foreach (FileSystemInfo info in directory.EnumerateFileSystemInfos("*", SearchOption.AllDirectories)) + { + info.Attributes = FileAttributes.Normal; + } + + directory.Delete(recursive: true); + } + + /// + /// "Escapes" Azure DevOps logging directives in repo content so that + /// logging it in a pipeline cannot trigger pipeline commands. + /// See https://github.com/dotnet/docker-tools/issues/1388. + /// + private static string EscapeVsoDirectives(string text) => + text.Replace("##vso", "#VSO_DIRECTIVE"); +} diff --git a/src/Automation/IGitContext.cs b/src/Automation/IGitContext.cs new file mode 100644 index 000000000..8852a4413 --- /dev/null +++ b/src/Automation/IGitContext.cs @@ -0,0 +1,24 @@ +// 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; + +/// +/// Provides access to a temporary repository checkout while an automation +/// operation is producing commits. +/// +public interface IGitContext +{ + /// + /// The root directory of the repository working tree. + /// + string Directory { get; } + + /// + /// Commits the current working tree changes, or returns null when there are + /// no changes to commit. Empty commit messages are rejected. + /// + /// The new commit, or null when the commit was skipped. + Task CommitAsync(string message, CancellationToken cancellationToken = default); +} diff --git a/src/Automation/IPullRequestApi.cs b/src/Automation/IPullRequestApi.cs new file mode 100644 index 000000000..a07922987 --- /dev/null +++ b/src/Automation/IPullRequestApi.cs @@ -0,0 +1,29 @@ +// 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; + +/// +/// The service-specific operations needed to manage pull requests and their +/// comments. All git operations are service-agnostic and handled by +/// ; implementations of this interface only deal +/// with the pull request itself. +/// +internal interface IPullRequestApi +{ + /// + /// Finds the open pull request from into + /// , or null if there is none. + /// + Task FindOpenAsync(string headBranch, string targetBranch, CancellationToken cancellationToken); + + Task CreateAsync( + string title, string body, string headBranch, string targetBranch, CancellationToken cancellationToken); + + Task UpdateAsync(long id, string title, string body, CancellationToken cancellationToken); + + Task> GetCommentsAsync(long id, CancellationToken cancellationToken); + + Task AddCommentAsync(long id, string comment, CancellationToken cancellationToken); +} diff --git a/src/Automation/IRepoHost.cs b/src/Automation/IRepoHost.cs new file mode 100644 index 000000000..3b6a77f3e --- /dev/null +++ b/src/Automation/IRepoHost.cs @@ -0,0 +1,36 @@ +// 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; + +/// +/// A repository on a hosting service (GitHub, Azure DevOps), exposed as a +/// reconciliation target: callers declare the desired state of a pull request +/// or branch, and the host makes reality match it. All operations are +/// idempotent — ensuring a state that already exists is a no-op. +/// +public interface IRepoHost +{ + /// + /// Ensures that an open pull request exists matching . + /// The pull request is identified by : + /// + /// No open pull request with the key → create the branch, apply the + /// changes, and open the pull request. + /// An open pull request already contains the desired changes → + /// no-op. + /// An open pull request exists with different content → update it + /// according to and + /// . + /// + /// + Task EnsurePullRequestAsync(PullRequestSpec spec, CancellationToken cancellationToken = default); + + /// + /// Ensures that the tip of a branch contains the changes described by + /// , committing and pushing them directly if they + /// are not already present. The push is fast-forward only. + /// + Task EnsureBranchContentAsync(BranchSpec spec, CancellationToken cancellationToken = default); +} diff --git a/src/Automation/Microsoft.DotNet.Automation.csproj b/src/Automation/Microsoft.DotNet.Automation.csproj new file mode 100644 index 000000000..404a2cc63 --- /dev/null +++ b/src/Automation/Microsoft.DotNet.Automation.csproj @@ -0,0 +1,25 @@ + + + + net9.0 + enable + true + true + + enable + + + + true + + false + Git automation library for pushing commits and submitting pull requests to GitHub and Azure DevOps. + + + + + + + + diff --git a/src/Automation/PullRequestInfo.cs b/src/Automation/PullRequestInfo.cs new file mode 100644 index 000000000..71d2aaedb --- /dev/null +++ b/src/Automation/PullRequestInfo.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; + +/// +/// A pull request as seen through . +/// +/// The host's identifier for the pull request (e.g. its number). +internal sealed record PullRequestInfo(long Id, string Url, string Title, string Body); diff --git a/src/Automation/PullRequestOutcome.cs b/src/Automation/PullRequestOutcome.cs new file mode 100644 index 000000000..48302f31a --- /dev/null +++ b/src/Automation/PullRequestOutcome.cs @@ -0,0 +1,37 @@ +// 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; + +/// +/// What an operation did. +/// +public enum PullRequestOutcome +{ + /// + /// The pull request already contained the desired changes; nothing was + /// pushed or modified. + /// + Unchanged, + + /// A new pull request was created. + Created, + + /// An existing pull request's content or metadata was updated. + Updated, + + /// + /// The operation stopped without updating the pull request, per policy + /// (e.g. foreign commits with + /// ). See + /// for why. + /// + Stopped, + + /// + /// This was a dry run: there were changes, but no pull request was created + /// or modified. + /// + DryRun, +} diff --git a/src/Automation/PullRequestResult.cs b/src/Automation/PullRequestResult.cs new file mode 100644 index 000000000..2990a020e --- /dev/null +++ b/src/Automation/PullRequestResult.cs @@ -0,0 +1,27 @@ +// 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; + +/// +/// The result of an operation: +/// what (if anything) had to change to reach the desired state. +/// +public sealed record PullRequestResult +{ + /// What the operation did. + public required PullRequestOutcome Outcome { get; init; } + + /// A link to the pull request, when one exists. + public string? Url { get; init; } + + /// The commits the automation pushed, in creation order. + public IReadOnlyList Commits { get; init; } = []; + + /// + /// Human-readable explanation, e.g. why the operation stopped. Set when + /// is . + /// + public string? Detail { get; init; } +} diff --git a/src/Automation/PullRequestSpec.cs b/src/Automation/PullRequestSpec.cs new file mode 100644 index 000000000..852307225 --- /dev/null +++ b/src/Automation/PullRequestSpec.cs @@ -0,0 +1,58 @@ +// 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; + +/// +/// The desired state of an automated pull request. +/// +public sealed record PullRequestSpec +{ + /// + /// A stable identifier for the pull request. It is used as the name of the + /// pull request's head branch, which is how the pull request is found + /// again on subsequent runs. Use the same key to update an existing pull + /// request; use a different key to open a separate pull request. + /// + public required string Key { get; init; } + + /// + /// The pull request title. Re-synced when the pull request is created or + /// updated; left unchanged when the operation stops. + /// + public required string Title { get; init; } + + /// + /// The pull request description. Re-synced when the pull request is created + /// or updated; left unchanged when the operation stops. + /// + public required string Body { get; init; } + + /// The branch that the pull request merges into. + public required string TargetBranch { get; init; } + + /// + /// Applies the desired changes to a local clone of the repo and creates + /// commits through the supplied . + /// + public required Func Apply { get; init; } + + /// How re-runs update an existing pull request. + public PullRequestUpdateStrategy UpdateStrategy { get; init; } = PullRequestUpdateStrategy.Append; + + /// + /// What to do when the pull request's branch contains commits that were + /// not authored by (e.g. another + /// actor pushed a fix to the bot's branch). + /// + public ForeignCommitPolicy OnForeignCommits { get; init; } = ForeignCommitPolicy.CommentAndStop; + + /// + /// Additional text appended to the comment posted when + /// stops an update, e.g. + /// instructions for applying the update manually. The comment always + /// lists the foreign commits that were found. + /// + public string? StopComment { get; init; } +} diff --git a/src/Automation/PullRequestUpdateStrategy.cs b/src/Automation/PullRequestUpdateStrategy.cs new file mode 100644 index 000000000..a18a7a10d --- /dev/null +++ b/src/Automation/PullRequestUpdateStrategy.cs @@ -0,0 +1,27 @@ +// 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; + +/// +/// Controls what happens when changes are pushed for a pull request that +/// already exists. +/// +public enum PullRequestUpdateStrategy +{ + /// + /// Add the new changes as an additional commit on top of the pull + /// request's existing commits. If the changes are already present in the + /// pull request, nothing is pushed. The safe default: existing history, + /// including any commits another actor pushed, is preserved. + /// + Append, + + /// + /// Reset the pull request to contain exactly the new changes on top of the + /// target branch (force push). If the resulting content is identical to + /// what the pull request already contains, nothing is pushed. + /// + Replace, +} diff --git a/src/Automation/README.md b/src/Automation/README.md new file mode 100644 index 000000000..c9823744f --- /dev/null +++ b/src/Automation/README.md @@ -0,0 +1,113 @@ +# Microsoft.DotNet.Automation + +A library for automating git commits and pull requests against GitHub +repositories. Requires the git CLI on `PATH`. + +Re-running any operation with the same desired state is a no-op, so scheduled +runs are safe. + +## Authentication + +You pass a single token via `GitAutomationOptions.Token`. A GitHub App +installation token or a personal access token both work; it needs write access +to push and to open pull requests. + +The token may be empty for read-only/anonymous access (e.g. a dry run against a +public repo). + +## Open or update a pull request + +`EnsurePullRequestAsync` opens a pull request, or updates the open one it finds +by `Key` (which is also the head branch name). + +```csharp +IRepoHost host = new GitHubRepoHost( + repo: new GitHubRepo("dotnet", "dotnet-docker"), + options: new GitAutomationOptions(token, author), + headRepo: new GitHubRepo("dotnet-bot", "dotnet-docker")); // optional fork + +PullRequestSpec spec = new() +{ + Key = "update-docker-tools-main", // also the head branch name + Title = "Update docker-tools files", + Body = "Automated update.", + TargetBranch = "main", + UpdateStrategy = PullRequestUpdateStrategy.Append, + OnForeignCommits = ForeignCommitPolicy.CommentAndStop, + Apply = async (context, cancellationToken) => + { + /* write files under context.Directory */ + await context.CommitAsync("Update docker-tools files", cancellationToken); + }, +}; + +AutomationResult result = await host.EnsurePullRequestAsync(spec); +``` + +## Commit directly to a branch + +`EnsureBranchContentAsync` commits changes straight to a branch using a +fast-forward push. It does not open a pull request. + +```csharp +BranchSpec spec = new() +{ + Branch = "main", + Apply = async (context, cancellationToken) => + { + await File.WriteAllTextAsync(Path.Combine(context.Directory, "readme.md"), content, cancellationToken); + await context.CommitAsync("Update readmes", cancellationToken); + }, +}; + +AutomationResult result = await host.EnsureBranchContentAsync(spec); +// result.CommitShas contains the pushed commits, or is empty if nothing needed to change. +``` + +## Multiple commits + +Call `IGitContext.CommitAsync` each time the current working tree should become +its own commit. Empty commits are skipped automatically. + +```csharp +PullRequestSpec spec = new() +{ + Key = "update-docker-tools-main", + Title = "Update docker-tools files", + Body = "Automated update.", + TargetBranch = "main", + Apply = async (context, cancellationToken) => + { + await UpdateScriptsAsync(context.Directory, cancellationToken); + await context.CommitAsync("Update shared docker-tools scripts", cancellationToken); + + await UpdateTemplatesAsync(context.Directory, cancellationToken); + await context.CommitAsync("Update pipeline templates", cancellationToken); + }, +}; +``` + +If no commits are produced, `EnsurePullRequestAsync` does not open a pull +request. + +## Options + +`PullRequestSpec.UpdateStrategy` controls how a re-run updates an existing pull +request: + +| `PullRequestUpdateStrategy` | Behavior | +| --- | --- | +| `Append` (default) | Add the new changes as a commit on top of the branch's existing history. | +| `Replace` | Force-push so the branch is exactly the new changes on top of the target branch. | + +`PullRequestSpec.OnForeignCommits` controls what to do when the branch contains +commits authored by someone other than the automation (e.g. another actor +pushed a fix to the bot's branch): + +| `ForeignCommitPolicy` | Behavior | +| --- | --- | +| `CommentAndStop` (default) | Leave the branch untouched and post a comment explaining why the update was skipped. | +| `Proceed` | Apply `UpdateStrategy` anyway. With `Append` the foreign commits are kept; with `Replace` they are discarded by the force-push. | + +In every case, if the desired content already matches the branch, nothing is +pushed. diff --git a/src/Automation/RemoteRepo.cs b/src/Automation/RemoteRepo.cs new file mode 100644 index 000000000..cc3c94582 --- /dev/null +++ b/src/Automation/RemoteRepo.cs @@ -0,0 +1,22 @@ +// 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; + +/// +/// Identifies a git repository hosted on a remote service. +/// +public abstract record RemoteRepo +{ + /// + /// The HTTPS URL used to clone the repository, without any credentials. + /// + public abstract Uri CloneUrl { get; } + + /// + /// The HTTPS URL used to clone the repository, with the given token + /// embedded as a credential. Never log this URL. + /// + internal abstract Uri GetAuthenticatedCloneUrl(string token); +} diff --git a/src/Automation/RepoHostEngine.cs b/src/Automation/RepoHostEngine.cs new file mode 100644 index 000000000..0ef2e9b06 --- /dev/null +++ b/src/Automation/RepoHostEngine.cs @@ -0,0 +1,280 @@ +// 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; + +/// +/// Service-agnostic implementation of the +/// reconciliation contract. All git operations are performed with the git CLI; +/// service-specific pull request operations are delegated to an +/// . +/// +/// The repository that pull requests merge into and branches are pushed to. +/// +/// The repository that pull request head branches are pushed to. Differs from +/// only when pull requests come from a fork. +/// +internal sealed class RepoHostEngine( + RemoteRepo targetRepo, + RemoteRepo headRepo, + IPullRequestApi pullRequests, + GitAutomationOptions options, + ILogger logger) : IRepoHost +{ + /// + public async Task EnsureBranchContentAsync( + BranchSpec spec, + CancellationToken cancellationToken = default) + { + GitCli git = new(logger, options.Token); + Uri url = targetRepo.GetAuthenticatedCloneUrl(options.Token); + + using GitWorkspace workspace = await GitWorkspace.CloneAsync(url, spec.Branch, options.Author, git, logger); + + GitContext context = await ApplyAsync(spec.Apply, workspace, cancellationToken); + + if (context.Commits.Count == 0) + { + logger.LogInformation("Branch '{Branch}' already contains the desired changes.", spec.Branch); + return new BranchResult { Outcome = BranchOutcome.Unchanged }; + } + + if (options.IsDryRun) + { + logger.LogInformation("Dry run: nothing was pushed to branch '{Branch}'.", spec.Branch); + return new BranchResult { Outcome = BranchOutcome.DryRun }; + } + + await workspace.PushAsync(url, spec.Branch); + + logger.LogInformation( + "Pushed commits {CommitShas} to branch '{Branch}'.", + string.Join(", ", context.Commits.Select(commit => commit.Sha)), + spec.Branch); + return new BranchResult { Outcome = BranchOutcome.Updated, Commits = context.Commits }; + } + + /// + public async Task EnsurePullRequestAsync( + PullRequestSpec spec, + CancellationToken cancellationToken = default) + { + GitCli git = new(logger, options.Token); + Uri targetUrl = targetRepo.GetAuthenticatedCloneUrl(options.Token); + Uri headUrl = headRepo.GetAuthenticatedCloneUrl(options.Token); + + using GitWorkspace workspace = + await GitWorkspace.CloneAsync(targetUrl, spec.TargetBranch, options.Author, git, logger); + string targetSha = await workspace.RevParseAsync("HEAD"); + + PullRequestInfo? pullRequest = await pullRequests.FindOpenAsync(spec.Key, spec.TargetBranch, cancellationToken); + return pullRequest is null + ? await CreateAsync(spec, workspace, headUrl, targetSha, cancellationToken) + : await UpdateAsync(spec, pullRequest, workspace, headUrl, targetSha, cancellationToken); + } + + private async Task CreateAsync( + PullRequestSpec spec, + GitWorkspace workspace, + Uri headUrl, + string targetSha, + CancellationToken cancellationToken) + { + await workspace.CheckoutNewBranchAsync(spec.Key); + GitContext context = await ApplyAsync(spec.Apply, workspace, cancellationToken); + + if (context.Commits.Count == 0) + { + logger.LogInformation( + "No commits were produced for pull request '{Key}' targeting branch '{TargetBranch}'.", + spec.Key, + spec.TargetBranch); + + return new PullRequestResult { Outcome = PullRequestOutcome.Unchanged }; + } + + // Creating the pull request force-recreates the head branch, so a + // stale head branch (e.g. from a closed pull request) carrying foreign + // commits blocks creation under CommentAndStop — "never destroy another + // actor's work" holds on the create path too. + if (spec.OnForeignCommits == ForeignCommitPolicy.CommentAndStop + && await workspace.RemoteBranchExistsAsync(headUrl, spec.Key)) + { + await workspace.FetchAsync(headUrl, spec.Key); + string staleHeadSha = await workspace.RevParseAsync("FETCH_HEAD"); + IReadOnlyList foreignCommits = await GetForeignCommitsAsync(workspace, staleHeadSha, targetSha); + + if (foreignCommits.Count > 0) + { + string detail = StopMessage(foreignCommits, spec.StopComment); + logger.LogWarning("{Detail}", detail); + return new PullRequestResult + { + Outcome = PullRequestOutcome.Stopped, + Detail = detail + }; + } + } + + if (options.IsDryRun) + { + logger.LogInformation("Dry run: no pull request was created for '{Key}'.", spec.Key); + return new PullRequestResult { Outcome = PullRequestOutcome.DryRun }; + } + + await workspace.PushAsync(headUrl, spec.Key, force: true); + + PullRequestInfo created = await pullRequests.CreateAsync( + title: spec.Title, + body: spec.Body, + headBranch: spec.Key, + targetBranch: spec.TargetBranch, + cancellationToken: cancellationToken); + + logger.LogInformation("Created pull request {Url}", created.Url); + return new PullRequestResult + { + Outcome = PullRequestOutcome.Created, + Url = created.Url, + Commits = context.Commits + }; + } + + private async Task UpdateAsync( + PullRequestSpec spec, + PullRequestInfo pullRequest, + GitWorkspace workspace, + Uri headUrl, + string targetSha, + CancellationToken cancellationToken) + { + await workspace.FetchAsync(headUrl, spec.Key); + string headSha = await workspace.RevParseAsync("FETCH_HEAD"); + + // With Append, changes are applied on top of the pull request's + // existing commits; otherwise on top of the target branch. + string baseSha = spec.UpdateStrategy == PullRequestUpdateStrategy.Append ? headSha : targetSha; + await workspace.CheckoutNewBranchAsync(spec.Key, baseSha); + + GitContext context = await ApplyAsync(spec.Apply, workspace, cancellationToken); + + string desiredTreeSha = await workspace.RevParseAsync("HEAD^{tree}"); + string headTreeSha = await workspace.RevParseAsync($"{headSha}^{{tree}}"); + bool contentChanged = context.Commits.Count > 0 && desiredTreeSha != headTreeSha; + bool metadataChanged = pullRequest.Title != spec.Title || pullRequest.Body != spec.Body; + + if (!contentChanged && !metadataChanged) + { + logger.LogInformation("Pull request {Url} is already up to date.", pullRequest.Url); + return new PullRequestResult { Outcome = PullRequestOutcome.Unchanged, Url = pullRequest.Url }; + } + + if (contentChanged) + { + IReadOnlyList foreignCommits = await GetForeignCommitsAsync(workspace, headSha, targetSha); + if (foreignCommits.Count > 0 && spec.OnForeignCommits == ForeignCommitPolicy.CommentAndStop) + { + string comment = StopMessage(foreignCommits, spec.StopComment); + logger.LogWarning("{Detail}", comment); + + // Post the explanation at most once so scheduled re-runs don't spam the pull request. + if (!options.IsDryRun + && !(await pullRequests.GetCommentsAsync(pullRequest.Id, cancellationToken)).Contains(comment)) + { + await pullRequests.AddCommentAsync(pullRequest.Id, comment, cancellationToken); + } + + return new PullRequestResult + { + Outcome = PullRequestOutcome.Stopped, + Url = pullRequest.Url, + Detail = comment + }; + } + } + + if (options.IsDryRun) + { + logger.LogInformation("Dry run: pull request {Url} was not updated.", pullRequest.Url); + return new PullRequestResult + { + Outcome = PullRequestOutcome.DryRun, + Url = pullRequest.Url + }; + } + + if (metadataChanged) + { + await pullRequests.UpdateAsync(pullRequest.Id, spec.Title, spec.Body, cancellationToken); + } + + if (!contentChanged) + { + logger.LogInformation("Updated title/description of pull request {Url}.", pullRequest.Url); + return new PullRequestResult + { + Outcome = PullRequestOutcome.Updated, + Url = pullRequest.Url + }; + } + + await workspace.PushAsync(headUrl, spec.Key, force: spec.UpdateStrategy == PullRequestUpdateStrategy.Replace); + + logger.LogInformation("Updated pull request {Url}", pullRequest.Url); + return new PullRequestResult + { + Outcome = PullRequestOutcome.Updated, + Url = pullRequest.Url, + Commits = context.Commits + }; + } + + private async Task ApplyAsync( + Func apply, + GitWorkspace workspace, + CancellationToken cancellationToken) + { + var context = new GitContext(workspace, options.Author, logger); + await apply(context, cancellationToken); + await context.ThrowIfPendingChangesAsync(); + return context; + } + + /// + /// Commits on the head branch that are not reachable from the target + /// branch and were not authored by the automation. + /// + private async Task> GetForeignCommitsAsync( + GitWorkspace workspace, + string headSha, + string targetSha) + { + var commits = await workspace.GetCommitsAsync(headSha, targetSha); + + var foreignCommits = commits + .Where(commit => commit.AuthorName != options.Author.Name || commit.AuthorEmail != options.Author.Email) + .ToList(); + + return foreignCommits; + } + + private static string StopMessage(IReadOnlyList foreignCommits, string? stopComment) + { + string commits = string.Join( + Environment.NewLine, + foreignCommits.Select(commit => $"- {commit.Sha}: {commit.Subject()} ({commit.AuthorName})")); + + string message = + $""" + This pull request was not updated automatically because its branch contains commits that were not authored by automation: + + {commits} + + """; + + return stopComment is null ? message : $"{message}{Environment.NewLine}{Environment.NewLine}{stopComment}"; + } +} From 67b29cba77f94c4dc4f82382232214b3c13a746c Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Mon, 22 Jun 2026 14:39:19 -0700 Subject: [PATCH 2/3] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- src/Automation/GitWorkspace.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Automation/GitWorkspace.cs b/src/Automation/GitWorkspace.cs index a52f65d88..a41503a2c 100644 --- a/src/Automation/GitWorkspace.cs +++ b/src/Automation/GitWorkspace.cs @@ -168,8 +168,13 @@ public void Dispose() } catch (DirectoryNotFoundException) { + _logger.LogDebug("Temporary directory {Path} was already deleted.", Path); } - catch (Exception e) + catch (IOException e) + { + _logger.LogWarning("Failed to delete temporary directory {Path}: {Message}", Path, e.Message); + } + catch (UnauthorizedAccessException e) { _logger.LogWarning("Failed to delete temporary directory {Path}: {Message}", Path, e.Message); } From 495bf3931c0bb4bcea3113304633895efeaf0379 Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Tue, 23 Jun 2026 11:22:58 -0700 Subject: [PATCH 3/3] Add Automation tests --- Microsoft.DotNet.DockerTools.slnx | 1 + src/Automation.Tests/AutomationHarness.cs | 89 +++++++++ .../AutomationHarnessTests.cs | 180 ++++++++++++++++++ src/Automation.Tests/FakePullRequestApi.cs | 89 +++++++++ src/Automation.Tests/GitRunner.cs | 53 ++++++ src/Automation.Tests/GitTestRepo.cs | 180 ++++++++++++++++++ src/Automation.Tests/GlobalUsings.cs | 7 + src/Automation.Tests/LocalRemoteRepo.cs | 21 ++ .../Microsoft.DotNet.Automation.Tests.csproj | 27 +++ src/Automation.Tests/TestParallelization.cs | 5 + src/Automation/GitHub/GitHubRepo.cs | 2 +- src/Automation/GitHub/GitHubRepoHost.cs | 4 +- src/Automation/IPullRequestApi.cs | 5 +- src/Automation/PullRequestInfo.cs | 5 +- src/Automation/RemoteRepo.cs | 2 +- src/Automation/RepoHostEngine.cs | 15 +- 16 files changed, 674 insertions(+), 11 deletions(-) create mode 100644 src/Automation.Tests/AutomationHarness.cs create mode 100644 src/Automation.Tests/AutomationHarnessTests.cs create mode 100644 src/Automation.Tests/FakePullRequestApi.cs create mode 100644 src/Automation.Tests/GitRunner.cs create mode 100644 src/Automation.Tests/GitTestRepo.cs create mode 100644 src/Automation.Tests/GlobalUsings.cs create mode 100644 src/Automation.Tests/LocalRemoteRepo.cs create mode 100644 src/Automation.Tests/Microsoft.DotNet.Automation.Tests.csproj create mode 100644 src/Automation.Tests/TestParallelization.cs diff --git a/Microsoft.DotNet.DockerTools.slnx b/Microsoft.DotNet.DockerTools.slnx index 7e3ead030..3e85015bc 100644 --- a/Microsoft.DotNet.DockerTools.slnx +++ b/Microsoft.DotNet.DockerTools.slnx @@ -8,6 +8,7 @@ + diff --git a/src/Automation.Tests/AutomationHarness.cs b/src/Automation.Tests/AutomationHarness.cs new file mode 100644 index 000000000..5f4c7a330 --- /dev/null +++ b/src/Automation.Tests/AutomationHarness.cs @@ -0,0 +1,89 @@ +// 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.Tests; + +/// +/// The front door of the test harness. Wires real local bare-git remotes (via +/// /) and an in-memory +/// into a . +/// +/// A test: +/// +/// creates a harness (the initial state of the world), +/// optionally seeds extra branches/commits (/) +/// and pull requests (), +/// takes an action ( / +/// ), +/// asserts on the result and on what was pushed / which pull request +/// operations ran. +/// +/// +internal sealed class AutomationHarness : IDisposable +{ + /// The identity the automation commits and pushes as. + public static readonly GitAuthor DefaultAuthor = new("automation-bot", "automation@example.test"); + + private readonly GitTestRepo _target; + private readonly GitTestRepo? _fork; + private readonly RepoHostEngine _engine; + + private AutomationHarness( + GitTestRepo target, GitTestRepo? fork, FakePullRequestApi pullRequests, RepoHostEngine engine) + { + _target = target; + _fork = fork; + PullRequests = pullRequests; + _engine = engine; + } + + /// The repository pull requests merge into and branches are pushed to. + public GitTestRepo Target => _target; + + /// + /// The repository pull request head branches are pushed to. The same as + /// unless the harness was created with a fork. + /// + public GitTestRepo Head => _fork ?? _target; + + /// The in-memory pull request API used to seed and inspect pull requests. + public FakePullRequestApi PullRequests { get; } + + /// Creates a harness whose target repo has a single commit on . + /// Automation settings; defaults to with an empty token. + /// The branch the target repo is initialized with. + /// When true, head branches are pushed to a separate fork remote. + public static async Task CreateAsync( + GitAutomationOptions? options = null, + string targetBranch = "main", + bool useFork = false) + { + options ??= new GitAutomationOptions(Token: string.Empty, Author: DefaultAuthor); + + GitTestRepo target = await GitTestRepo.InitAsync(options.Author, targetBranch); + GitTestRepo? fork = useFork ? await GitTestRepo.InitAsync(options.Author, targetBranch) : null; + + FakePullRequestApi pullRequests = new(); + LocalRemoteRepo targetRepo = new(target.Url); + LocalRemoteRepo headRepo = new((fork ?? target).Url); + + RepoHostEngine engine = new(targetRepo, headRepo, pullRequests, options); + + return new AutomationHarness(target, fork, pullRequests, engine); + } + + public Task EnsurePullRequestAsync( + PullRequestSpec spec, CancellationToken cancellationToken = default) => + _engine.EnsurePullRequestAsync(spec, cancellationToken); + + public Task EnsureBranchContentAsync( + BranchSpec spec, CancellationToken cancellationToken = default) => + _engine.EnsureBranchContentAsync(spec, cancellationToken); + + public void Dispose() + { + _target.Dispose(); + _fork?.Dispose(); + } +} diff --git a/src/Automation.Tests/AutomationHarnessTests.cs b/src/Automation.Tests/AutomationHarnessTests.cs new file mode 100644 index 000000000..80b5188de --- /dev/null +++ b/src/Automation.Tests/AutomationHarnessTests.cs @@ -0,0 +1,180 @@ +// 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.Tests; + +/// +/// Representative tests that double as examples of how to use the harness: +/// set up a state of the world, take an action in the library, then assert on +/// what the library did. +/// +[TestClass] +public sealed class AutomationHarnessTests +{ + private static readonly GitAuthor s_foreignAuthor = new("other-contributor", "other@example.test"); + + [TestMethod] + public async Task EnsurePullRequest_NoExistingPullRequest_CreatesPullRequest() + { + using AutomationHarness harness = await AutomationHarness.CreateAsync(); + + PullRequestSpec spec = new() + { + Key = "update-tools", + Title = "Update tools", + Body = "Automated update.", + TargetBranch = "main", + Apply = WriteFile("tools.txt", "v1", "Add tools"), + }; + + PullRequestResult result = await harness.EnsurePullRequestAsync(spec); + + result.Outcome.ShouldBe(PullRequestOutcome.Created); + result.Commits.Select(commit => commit.Message).ShouldBe(["Add tools"]); + + // The library opened exactly one pull request with the requested metadata. + harness.PullRequests.Creates.Count.ShouldBe(1); + RecordedCreate created = harness.PullRequests.Creates[0]; + created.Title.ShouldBe("Update tools"); + created.Body.ShouldBe("Automated update."); + created.HeadBranch.ShouldBe("update-tools"); + created.TargetBranch.ShouldBe("main"); + + // The head branch was pushed to the remote with the applied content. + (await harness.Head.BranchExistsAsync("update-tools")).ShouldBeTrue(); + (await harness.Head.GetFileAtRefAsync("update-tools", "tools.txt")).ShouldBe("v1"); + } + + [TestMethod] + public async Task EnsurePullRequest_BranchAlreadyMatches_IsNoOp() + { + using AutomationHarness harness = await AutomationHarness.CreateAsync(); + + // State of the world: an open pull request whose branch already has the desired content. + await harness.Head.SeedBranchAsync( + branch: "update-tools", + fromBranch: "main", + relativePath: "tools.txt", + content: "v1", + author: AutomationHarness.DefaultAuthor, + message: "Add tools"); + harness.PullRequests.SeedOpenPullRequest("update-tools", "main", "Update tools", "Automated update."); + + PullRequestSpec spec = new() + { + Key = "update-tools", + Title = "Update tools", + Body = "Automated update.", + TargetBranch = "main", + Apply = WriteFile("tools.txt", "v1", "Add tools"), + }; + + PullRequestResult result = await harness.EnsurePullRequestAsync(spec); + + result.Outcome.ShouldBe(PullRequestOutcome.Unchanged); + harness.PullRequests.Creates.ShouldBeEmpty(); + harness.PullRequests.Updates.ShouldBeEmpty(); + } + + [TestMethod] + public async Task EnsurePullRequest_ForeignCommitOnBranch_StopsAndCommentsOnce() + { + using AutomationHarness harness = await AutomationHarness.CreateAsync(); + + // State of the world: the branch carries a commit by someone other than the automation. + await harness.Head.SeedBranchAsync( + branch: "update-tools", + fromBranch: "main", + relativePath: "hotfix.txt", + content: "manual patch", + author: s_foreignAuthor, + message: "Apply hotfix"); + long pullRequestId = + harness.PullRequests.SeedOpenPullRequest("update-tools", "main", "Update tools", "Automated update."); + + string headTipBefore = await harness.Head.GetBranchTipAsync("update-tools"); + + PullRequestSpec spec = new() + { + Key = "update-tools", + Title = "Update tools", + Body = "Automated update.", + TargetBranch = "main", + // CommentAndStop is the default; applying different content would otherwise update the branch. + Apply = WriteFile("tools.txt", "v2", "Update tools"), + }; + + PullRequestResult first = await harness.EnsurePullRequestAsync(spec); + + first.Outcome.ShouldBe(PullRequestOutcome.Stopped); + first.Detail.ShouldNotBeNull(); + first.Detail.ShouldContain(s_foreignAuthor.Name); + + // The foreign work was left untouched and the explanation was posted once. + (await harness.Head.GetBranchTipAsync("update-tools")).ShouldBe(headTipBefore); + harness.PullRequests.Comments.Count.ShouldBe(1); + harness.PullRequests.Comments[0].PullRequestId.ShouldBe(pullRequestId); + + // A scheduled re-run does not post the same comment again. + PullRequestResult second = await harness.EnsurePullRequestAsync(spec); + + second.Outcome.ShouldBe(PullRequestOutcome.Stopped); + harness.PullRequests.Comments.Count.ShouldBe(1); + } + + [TestMethod] + public async Task EnsureBranchContent_NewChanges_FastForwardsBranch() + { + using AutomationHarness harness = await AutomationHarness.CreateAsync(); + string tipBefore = await harness.Target.GetBranchTipAsync("main"); + + BranchSpec spec = new() + { + Branch = "main", + Apply = WriteFile("notes.txt", "hello", "Add notes"), + }; + + BranchResult result = await harness.EnsureBranchContentAsync(spec); + + result.Outcome.ShouldBe(BranchOutcome.Updated); + result.Commits.Count.ShouldBe(1); + + string tipAfter = await harness.Target.GetBranchTipAsync("main"); + tipAfter.ShouldNotBe(tipBefore); + tipAfter.ShouldBe(result.Commits[0].Sha); + (await harness.Target.GetFileAtRefAsync("main", "notes.txt")).ShouldBe("hello"); + } + + [TestMethod] + public async Task EnsurePullRequest_DryRun_PushesNothingAndCreatesNoPullRequest() + { + GitAutomationOptions options = + new(Token: string.Empty, Author: AutomationHarness.DefaultAuthor, IsDryRun: true); + using AutomationHarness harness = await AutomationHarness.CreateAsync(options); + + PullRequestSpec spec = new() + { + Key = "update-tools", + Title = "Update tools", + Body = "Automated update.", + TargetBranch = "main", + Apply = WriteFile("tools.txt", "v1", "Add tools"), + }; + + PullRequestResult result = await harness.EnsurePullRequestAsync(spec); + + result.Outcome.ShouldBe(PullRequestOutcome.DryRun); + harness.PullRequests.Creates.ShouldBeEmpty(); + (await harness.Head.BranchExistsAsync("update-tools")).ShouldBeFalse(); + } + + private static Func WriteFile( + string relativePath, string content, string commitMessage) => + async (context, cancellationToken) => + { + await File.WriteAllTextAsync( + Path.Combine(context.Directory, relativePath), content, cancellationToken); + await context.CommitAsync(commitMessage, cancellationToken); + }; +} diff --git a/src/Automation.Tests/FakePullRequestApi.cs b/src/Automation.Tests/FakePullRequestApi.cs new file mode 100644 index 000000000..b2562a388 --- /dev/null +++ b/src/Automation.Tests/FakePullRequestApi.cs @@ -0,0 +1,89 @@ +// 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.Tests; + +/// A pull request creation recorded by . +internal sealed record RecordedCreate(long Id, string Title, string Body, string HeadBranch, string TargetBranch); + +/// A pull request metadata update recorded by . +internal sealed record RecordedUpdate(long Id, string Title, string Body); + +/// A comment added to a pull request, recorded by . +internal sealed record RecordedComment(long PullRequestId, string Body); + +/// +/// An in-memory for tests. Lets a test seed the +/// "state of the world" (pre-existing open pull requests) and then assert on the +/// pull request operations the library performed (create, update, comment). +/// +internal sealed class FakePullRequestApi : IPullRequestApi +{ + private readonly List _pullRequests = []; + private readonly List _creates = []; + private readonly List _updates = []; + private readonly List _comments = []; + private long _nextId = 1; + + public IReadOnlyList Creates => _creates; + + public IReadOnlyList Updates => _updates; + + public IReadOnlyList Comments => _comments; + + /// Seeds a pre-existing open pull request and returns its id. + public long SeedOpenPullRequest( + string headBranch, string targetBranch, string title, string body, string? url = null) + { + long id = _nextId++; + _pullRequests.Add(new StoredPullRequest( + id, url ?? $"https://example.test/pull/{id}", title, body, headBranch, targetBranch)); + return id; + } + + public Task FindOpenAsync( + string headBranch, string targetBranch, CancellationToken cancellationToken) + { + StoredPullRequest? match = _pullRequests.FirstOrDefault( + pullRequest => pullRequest.HeadBranch == headBranch && pullRequest.TargetBranch == targetBranch); + + return Task.FromResult( + match is null ? null : new PullRequestInfo(match.Id, match.Url, match.Title, match.Body)); + } + + public Task CreateAsync( + string title, string body, string headBranch, string targetBranch, CancellationToken cancellationToken) + { + long id = _nextId++; + string url = $"https://example.test/pull/{id}"; + _pullRequests.Add(new StoredPullRequest(id, url, title, body, headBranch, targetBranch)); + _creates.Add(new RecordedCreate(id, title, body, headBranch, targetBranch)); + return Task.FromResult(new PullRequestInfo(id, url, title, body)); + } + + public Task UpdateAsync(long id, string title, string body, CancellationToken cancellationToken) + { + int index = _pullRequests.FindIndex(pullRequest => pullRequest.Id == id); + if (index >= 0) + { + _pullRequests[index] = _pullRequests[index] with { Title = title, Body = body }; + } + + _updates.Add(new RecordedUpdate(id, title, body)); + return Task.CompletedTask; + } + + public Task> GetCommentsAsync(long id, CancellationToken cancellationToken) => + Task.FromResult>( + [.. _comments.Where(comment => comment.PullRequestId == id).Select(comment => comment.Body)]); + + public Task AddCommentAsync(long id, string comment, CancellationToken cancellationToken) + { + _comments.Add(new RecordedComment(id, comment)); + return Task.CompletedTask; + } + + private sealed record StoredPullRequest( + long Id, string Url, string Title, string Body, string HeadBranch, string TargetBranch); +} diff --git a/src/Automation.Tests/GitRunner.cs b/src/Automation.Tests/GitRunner.cs new file mode 100644 index 000000000..4b11ddc83 --- /dev/null +++ b/src/Automation.Tests/GitRunner.cs @@ -0,0 +1,53 @@ +// 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; + +namespace Microsoft.DotNet.Automation.Tests; + +/// +/// Runs the real git CLI for test setup and assertions. Mirrors how the +/// library itself shells out to git, so scenarios exercise genuine git +/// behavior rather than a simulation. +/// +internal static class GitRunner +{ + public static async Task RunAsync(string? workingDirectory, 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); + } + + using Process process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Failed to start git process."); + + Task outputTask = process.StandardOutput.ReadToEndAsync(); + Task errorTask = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + + string output = await outputTask; + string error = await errorTask; + + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"git {string.Join(' ', args)} failed with exit code {process.ExitCode}.{Environment.NewLine}{error}"); + } + + return output; + } +} diff --git a/src/Automation.Tests/GitTestRepo.cs b/src/Automation.Tests/GitTestRepo.cs new file mode 100644 index 000000000..3b9fa7af1 --- /dev/null +++ b/src/Automation.Tests/GitTestRepo.cs @@ -0,0 +1,180 @@ +// 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.Tests; + +/// +/// A single commit read back from a , used for +/// assertions about what the automation pushed. +/// +internal sealed record TestCommit(string Sha, string AuthorName, string AuthorEmail, string Subject); + +/// +/// A throwaway git repository used as a remote in tests. It is backed by a real +/// bare repository on disk (the "remote" the library clones from and pushes to) +/// plus an internal working clone used to seed branches and commits. Read +/// helpers let tests assert on what the automation actually pushed. +/// +internal sealed class GitTestRepo : IDisposable +{ + private readonly string _rootPath; + private readonly string _barePath; + private readonly string _workPath; + + private GitTestRepo(string rootPath, string barePath, string workPath) + { + _rootPath = rootPath; + _barePath = barePath; + _workPath = workPath; + } + + /// The clone URL of the bare remote, as a file:// URI. + public Uri Url => new(_barePath); + + /// + /// Creates a bare remote with a single initial commit on + /// . + /// + public static async Task InitAsync( + GitAuthor initialAuthor, + string defaultBranch, + string seedFile = "README.md", + string seedContent = "initial") + { + string rootPath = CreateTempDirectory(); + try + { + string barePath = Path.Combine(rootPath, "remote.git"); + string workPath = Path.Combine(rootPath, "work"); + + await GitRunner.RunAsync(null, "init", "--bare", "--initial-branch", defaultBranch, barePath); + + // Allow real partial clones (the library clones with --filter=blob:none). + // --git-dir is required because git refuses to treat a bare repo as the + // current repository implicitly when safe.bareRepository=explicit. + await GitRunner.RunAsync(null, $"--git-dir={barePath}", "config", "uploadpack.allowFilter", "true"); + + await GitRunner.RunAsync(null, "init", "--initial-branch", defaultBranch, workPath); + await WriteAndCommitAsync(workPath, seedFile, seedContent, initialAuthor, "Initial commit"); + await GitRunner.RunAsync(workPath, "push", new Uri(barePath).AbsoluteUri, defaultBranch); + + return new GitTestRepo(rootPath, barePath, workPath); + } + catch + { + // Don't leak the temp directory if setup fails before the instance + // (and therefore its Dispose) exists. + TryDeleteDirectory(rootPath); + throw; + } + } + + /// + /// Seeds a branch (created from ) with a commit + /// authored by , then pushes it to the bare remote. + /// Use a non-automation author to simulate a foreign commit. + /// + public async Task SeedBranchAsync( + string branch, string fromBranch, string relativePath, string content, GitAuthor author, string message) + { + await GitRunner.RunAsync(_workPath, "checkout", "-B", branch, fromBranch); + string sha = await WriteAndCommitAsync(_workPath, relativePath, content, author, message); + await GitRunner.RunAsync(_workPath, "push", "--force", Url.AbsoluteUri, branch); + return sha; + } + + /// Resolves the SHA at the tip of a branch on the bare remote. + public async Task GetBranchTipAsync(string branch) => + (await RunInBareAsync("rev-parse", $"refs/heads/{branch}")).Trim(); + + /// Returns whether a branch exists on the bare remote. + public async Task BranchExistsAsync(string branch) + { + string output = (await RunInBareAsync( + "for-each-ref", "--format=%(refname:short)", $"refs/heads/{branch}")).Trim(); + return !string.IsNullOrEmpty(output); + } + + /// Reads the content of a file at a given revision on the bare remote. + public Task GetFileAtRefAsync(string revision, string relativePath) => + RunInBareAsync("show", $"{revision}:{relativePath}"); + + /// Lists the commits on a branch (most recent first) on the bare remote. + public async Task> GetCommitsAsync(string branch) + { + const char fieldSeparator = '\x1f'; + string output = (await RunInBareAsync( + "log", "--format=%H%x1f%an%x1f%ae%x1f%s", $"refs/heads/{branch}")).Trim(); + + if (string.IsNullOrEmpty(output)) + { + return []; + } + + return + [ + .. output.Split('\n').Select(line => + { + string[] fields = line.Split(fieldSeparator); + return new TestCommit(fields[0], fields[1], fields[2], fields[3]); + }), + ]; + } + + public void Dispose() => TryDeleteDirectory(_rootPath); + + private Task RunInBareAsync(params string[] args) => + GitRunner.RunAsync(null, [$"--git-dir={_barePath}", .. args]); + + private static async Task WriteAndCommitAsync( + string workPath, string relativePath, string content, GitAuthor author, string message) + { + string fullPath = Path.Combine(workPath, relativePath); + string? directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + await File.WriteAllTextAsync(fullPath, content); + await GitRunner.RunAsync(workPath, "add", "--all"); + await GitRunner.RunAsync( + workPath, + "-c", $"user.name={author.Name}", + "-c", $"user.email={author.Email}", + "commit", "--message", message); + + return (await GitRunner.RunAsync(workPath, "rev-parse", "HEAD")).Trim(); + } + + private static string CreateTempDirectory() + { + string path = Path.Combine(Path.GetTempPath(), $"automation-test-{Path.GetRandomFileName()}"); + Directory.CreateDirectory(path); + return path; + } + + private static void TryDeleteDirectory(string path) + { + try + { + DeleteDirectory(new DirectoryInfo(path)); + } + catch (DirectoryNotFoundException) + { + // Already gone. + } + } + + private static void DeleteDirectory(DirectoryInfo directory) + { + // Files under .git are read-only and must be made writable before deletion on Windows. + foreach (FileSystemInfo info in directory.EnumerateFileSystemInfos("*", SearchOption.AllDirectories)) + { + info.Attributes = FileAttributes.Normal; + } + + directory.Delete(recursive: true); + } +} diff --git a/src/Automation.Tests/GlobalUsings.cs b/src/Automation.Tests/GlobalUsings.cs new file mode 100644 index 000000000..9c0930c1a --- /dev/null +++ b/src/Automation.Tests/GlobalUsings.cs @@ -0,0 +1,7 @@ +// 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. + +global using Microsoft.DotNet.Automation; +global using Microsoft.VisualStudio.TestTools.UnitTesting; +global using Shouldly; diff --git a/src/Automation.Tests/LocalRemoteRepo.cs b/src/Automation.Tests/LocalRemoteRepo.cs new file mode 100644 index 000000000..846886cba --- /dev/null +++ b/src/Automation.Tests/LocalRemoteRepo.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.Tests; + +/// +/// A that points at a local repository (e.g. a bare +/// repo on disk exposed via a file:// URL), so automation operations run +/// fully offline against real git. The token is irrelevant for local repos and +/// is ignored. +/// +internal sealed record LocalRemoteRepo(Uri Url) : RemoteRepo +{ + public override Uri CloneUrl => Url; + + // protected (not protected internal): the base member is protected internal, + // but its internal half is not accessible across assemblies, so an external + // override is declared protected. + protected override Uri GetAuthenticatedCloneUrl(string token) => Url; +} 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..f0001b9d7 --- /dev/null +++ b/src/Automation.Tests/Microsoft.DotNet.Automation.Tests.csproj @@ -0,0 +1,27 @@ + + + + net9.0 + false + true + true + enable + enable + + + + true + false + MSTest + + + + + + + + + + + + diff --git a/src/Automation.Tests/TestParallelization.cs b/src/Automation.Tests/TestParallelization.cs new file mode 100644 index 000000000..f6017f92b --- /dev/null +++ b/src/Automation.Tests/TestParallelization.cs @@ -0,0 +1,5 @@ +// 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. + +[assembly: Parallelize(Workers = 0, Scope = ExecutionScope.ClassLevel)] diff --git a/src/Automation/GitHub/GitHubRepo.cs b/src/Automation/GitHub/GitHubRepo.cs index 1ca389f77..0fa32cebe 100644 --- a/src/Automation/GitHub/GitHubRepo.cs +++ b/src/Automation/GitHub/GitHubRepo.cs @@ -13,7 +13,7 @@ public sealed record GitHubRepo(string Owner, string Name) : RemoteRepo { public override Uri CloneUrl => new($"https://github.com/{Owner}/{Name}"); - internal override Uri GetAuthenticatedCloneUrl(string token) => + protected internal override Uri GetAuthenticatedCloneUrl(string token) => string.IsNullOrEmpty(token) ? CloneUrl : new Uri($"https://x-access-token:{Uri.EscapeDataString(token)}@github.com/{Owner}/{Name}"); diff --git a/src/Automation/GitHub/GitHubRepoHost.cs b/src/Automation/GitHub/GitHubRepoHost.cs index 3d2f86868..8cb56d5ba 100644 --- a/src/Automation/GitHub/GitHubRepoHost.cs +++ b/src/Automation/GitHub/GitHubRepoHost.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for more information. using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; using Octokit; namespace Microsoft.DotNet.Automation.GitHub; @@ -40,13 +39,12 @@ internal GitHubRepoHost( IGitHubClient gitHubClient) { headRepo ??= repo; - ILogger logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); _engine = new RepoHostEngine( repo, headRepo, new GitHubPullRequestApi(gitHubClient, repo, headRepo), options, - logger); + loggerFactory); } /// diff --git a/src/Automation/IPullRequestApi.cs b/src/Automation/IPullRequestApi.cs index a07922987..c90055137 100644 --- a/src/Automation/IPullRequestApi.cs +++ b/src/Automation/IPullRequestApi.cs @@ -8,9 +8,10 @@ namespace Microsoft.DotNet.Automation; /// The service-specific operations needed to manage pull requests and their /// comments. All git operations are service-agnostic and handled by /// ; implementations of this interface only deal -/// with the pull request itself. +/// with the pull request itself. A host service (GitHub, Azure DevOps) provides +/// an implementation; tests can provide an in-memory fake. /// -internal interface IPullRequestApi +public interface IPullRequestApi { /// /// Finds the open pull request from into diff --git a/src/Automation/PullRequestInfo.cs b/src/Automation/PullRequestInfo.cs index 71d2aaedb..608ec8e51 100644 --- a/src/Automation/PullRequestInfo.cs +++ b/src/Automation/PullRequestInfo.cs @@ -8,4 +8,7 @@ namespace Microsoft.DotNet.Automation; /// A pull request as seen through . /// /// The host's identifier for the pull request (e.g. its number). -internal sealed record PullRequestInfo(long Id, string Url, string Title, string Body); +/// A link to the pull request. +/// The pull request title. +/// The pull request description. +public sealed record PullRequestInfo(long Id, string Url, string Title, string Body); diff --git a/src/Automation/RemoteRepo.cs b/src/Automation/RemoteRepo.cs index cc3c94582..c5445e866 100644 --- a/src/Automation/RemoteRepo.cs +++ b/src/Automation/RemoteRepo.cs @@ -18,5 +18,5 @@ public abstract record RemoteRepo /// The HTTPS URL used to clone the repository, with the given token /// embedded as a credential. Never log this URL. /// - internal abstract Uri GetAuthenticatedCloneUrl(string token); + protected internal abstract Uri GetAuthenticatedCloneUrl(string token); } diff --git a/src/Automation/RepoHostEngine.cs b/src/Automation/RepoHostEngine.cs index 0ef2e9b06..94b7cd411 100644 --- a/src/Automation/RepoHostEngine.cs +++ b/src/Automation/RepoHostEngine.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace Microsoft.DotNet.Automation; @@ -10,20 +11,28 @@ namespace Microsoft.DotNet.Automation; /// Service-agnostic implementation of the /// reconciliation contract. All git operations are performed with the git CLI; /// service-specific pull request operations are delegated to an -/// . +/// . Compose this directly to target a host that +/// does not have a dedicated wrapper, or to inject a +/// custom (e.g. an in-memory fake in tests). /// /// The repository that pull requests merge into and branches are pushed to. /// /// The repository that pull request head branches are pushed to. Differs from /// only when pull requests come from a fork. /// -internal sealed class RepoHostEngine( +/// The service-specific pull request operations. +/// Common automation settings. +/// Used to create the engine's logger; no logging occurs when null. +public sealed class RepoHostEngine( RemoteRepo targetRepo, RemoteRepo headRepo, IPullRequestApi pullRequests, GitAutomationOptions options, - ILogger logger) : IRepoHost + ILoggerFactory? loggerFactory = null) : IRepoHost { + private readonly ILogger logger = + (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + /// public async Task EnsureBranchContentAsync( BranchSpec spec,