From 198af3c57a57d5519bb86fa37e6b17f10731f3e9 Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Tue, 7 Jul 2026 17:10:41 -0700 Subject: [PATCH 01/16] Add Automation library Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- 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 + 15 files changed, 1094 insertions(+) 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/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 From 082932ad876e1a359712abaae78676e12a757ad6 Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Wed, 8 Jul 2026 10:00:36 -0700 Subject: [PATCH 02/16] Update README for Automation library --- src/Automation/README.md | 76 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/Automation/README.md b/src/Automation/README.md index 6df908c64..13e6a9777 100644 --- a/src/Automation/README.md +++ b/src/Automation/README.md @@ -1 +1,77 @@ # Microsoft.DotNet.Automation + +`Microsoft.DotNet.Automation` is a library for declaratively managing +pull requests and (eventually) issues. + +This pattern is most useful for automation that repeatedly opens or updates +similar pull requests or issues, like dependency or version updates. + +## Features + +| Feature | GitHub | Azure DevOps | +| ------- | ------ | ------------ | +| Pull requests | ✅ | - | +| Issues | - | - | +| Groups of issues | - | - | + +The feature matrix will be filled out incrementally (as needed) in order to +accomplish https://github.com/dotnet/docker-tools/issues/1658. + +## Usage + +### Open a pull request + +```csharp +using Microsoft.DotNet.Automation; +using Microsoft.DotNet.Automation.GitHub; + +// Instantiate the pull request manager. +var pullRequestManager = new PullRequestManager( + token: Environment.GetEnvironmentVariable("GITHUB_TOKEN") ?? "", + upstream: new GitHubRepo("dotnet", "example"), + identity: new AutomationIdentity("bot", "bot@example.com"), + // Optional args: + // To submit the PR from a fork: + // fork: new GitHubRepo("bot-account", "example"), + // Microsoft.Extensions.Logging support: + // loggerFactory: ... +); + +// Declare the pull request that you want to create. +var pullRequest = new PullRequestDefinition( + // `Key` is the sole method used to identify pull requests managed by this automation. + // There will never be two pull requests open against the same repo with the same key. + // To open two simultaneous pull requests against the same repo, use different keys. + // All other properties of a pull request definition are free to change between runs. + Key: "version-updates/update-generated-files", + Title: "Update dependencies", + Body: "...", + TargetBranch: "main", + ApplyChanges: async (git, ct) => + { + string generatedFile = Path.Combine(git.WorkspaceDirectory, "generated.txt"); + await File.WriteAllTextAsync(generatedFile, "...", ct); + await git.CommitAsync("Update generated.txt", ct); + } +); + +// The manager either creates a new pull request or updates the existing pull +// request if one is already open. +PullRequestResult result = await pullRequestManager.CreateOrUpdateAsync( + definition: pullRequest, + + // Update strategies: + // - Append: take the existing branch and add new commits on top. + // - Replace: take the latest changes from the target branch, make commits + // on top, and force push to the source branch. + updateStrategy: PullRequestUpdateStrategy.Append, + + // - Proceed: ignore commits that weren't authored by this automation and + // continue with the chosen update strategy. + // - Stop: refuse to make changes to a PR if it contains commits that + // weren't authored by this automation. + onForeignCommits: ForeignCommitPolicy.Proceed, + + cancellationToken: ct +); +``` From c21bf4e6e0faef8d9545585d3ad968785edd516b Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Wed, 8 Jul 2026 10:25:12 -0700 Subject: [PATCH 03/16] Handle scenario where source branch exists but there is no PR --- .../PullRequestPlannerTests.cs | 28 +++++++++++++++++-- src/Automation/GitHub/GitHubRepoHost.cs | 2 +- src/Automation/Models.cs | 2 +- src/Automation/Planner.cs | 4 ++- src/Automation/README.md | 10 +++++-- 5 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/Automation.Tests/PullRequestPlannerTests.cs b/src/Automation.Tests/PullRequestPlannerTests.cs index f022496e8..7aa0755dd 100644 --- a/src/Automation.Tests/PullRequestPlannerTests.cs +++ b/src/Automation.Tests/PullRequestPlannerTests.cs @@ -85,6 +85,8 @@ public IEnumerable Plan() => onForeignCommits: OnForeignCommits); }; + // If no PR exists, and there are changes to be made, then a new PR is + // always created. [TestMethod] public void ChangesWithNoPR_CreatesNewPR() { @@ -93,7 +95,7 @@ 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 + // Collision should be very unlikely, but filter it out anyways where desiredState.TreeHash != targetBranch.TreeHash select new PullRequestScenario( DesiredState: desiredState, @@ -105,6 +107,28 @@ from onForeignCommits in GenForeignCommitPolicy scenario.Sample(s => s.Plan().OfType().Count() == 1); } + // If no PR exists, and there are changes to be made, the source branch is + // always force pushed to reset it to the state of the target branch. + [TestMethod] + public void ChangesWithNoPR_ResetsExistingBranch() + { + var scenario = + from desiredState in GenPullRequestState + from targetBranch in GenTargetBranchState + from updateStrategy in GenUpdateStrategy + from onForeignCommits in GenForeignCommitPolicy + // Collision should be very unlikely, but filter it out anyways + where desiredState.TreeHash != targetBranch.TreeHash + select new PullRequestScenario( + DesiredState: desiredState, + TargetBranch: targetBranch, + ExistingPullRequest: null, + UpdateStrategy: updateStrategy, + OnForeignCommits: onForeignCommits); + + scenario.Sample(s => s.Plan().OfType().Single().ForcePush); + } + // For all scenarios where the desired tree is already present in an // existing pull request, no actions are taken. [TestMethod] @@ -151,7 +175,7 @@ from updateStrategy in GenUpdateStrategy scenario.Sample(s => !s.Plan().OfType().Any()); } - // create operation is only ever produced when no pull request exists. + // If there is already an open PR, never decide to create a second one. [TestMethod] public void ExistingPR_DoesNotCreateNewPR() { diff --git a/src/Automation/GitHub/GitHubRepoHost.cs b/src/Automation/GitHub/GitHubRepoHost.cs index e2b9a9588..82fdfbf68 100644 --- a/src/Automation/GitHub/GitHubRepoHost.cs +++ b/src/Automation/GitHub/GitHubRepoHost.cs @@ -122,7 +122,7 @@ private async Task PushAsync(PushCommitsOperation operation, Canc 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; + bool forcePush = operation.ForcePush; string[] pushArgs = forcePush ? ["push", "--force", authUrl, $"HEAD:{remoteRef}"] : ["push", authUrl, $"HEAD:{remoteRef}"]; diff --git a/src/Automation/Models.cs b/src/Automation/Models.cs index c9639893d..a96975671 100644 --- a/src/Automation/Models.cs +++ b/src/Automation/Models.cs @@ -97,7 +97,7 @@ public sealed record CommitInfo(string Sha, string AuthorName, string AuthorEmai // 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 PushCommitsOperation(string WorkspaceDirectory, string SourceBranch, bool ForcePush) : 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; diff --git a/src/Automation/Planner.cs b/src/Automation/Planner.cs index b758070f3..64cb1c0ad 100644 --- a/src/Automation/Planner.cs +++ b/src/Automation/Planner.cs @@ -34,10 +34,12 @@ ForeignCommitPolicy onForeignCommits if (hasContentDiff) { + bool forcePush = existingPullRequest is null || updateStrategy == PullRequestUpdateStrategy.Replace; + operations.Add(new PushCommitsOperation( workspaceDirectory, desiredState.Key, - updateStrategy)); + forcePush)); } if (existingPullRequest is null) diff --git a/src/Automation/README.md b/src/Automation/README.md index 13e6a9777..5692b9ee7 100644 --- a/src/Automation/README.md +++ b/src/Automation/README.md @@ -61,9 +61,13 @@ PullRequestResult result = await pullRequestManager.CreateOrUpdateAsync( definition: pullRequest, // Update strategies: - // - Append: take the existing branch and add new commits on top. - // - Replace: take the latest changes from the target branch, make commits - // on top, and force push to the source branch. + // - Append: for an existing pull request, take the source branch and add new + // commits on top. + // - Replace: for an existing pull request, take the latest changes from the + // target branch, make commits on top, and force push to the source branch. + // + // When no pull request exists, the source branch is reset to the state of + // the target branch no matter which strategy is selected. updateStrategy: PullRequestUpdateStrategy.Append, // - Proceed: ignore commits that weren't authored by this automation and From 7c634c9b3339f0f94613ca331783710c0df3a07f Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Wed, 8 Jul 2026 10:28:22 -0700 Subject: [PATCH 04/16] Update test file name --- .../{PullRequestPlannerTests.cs => PullRequestPropertyTests.cs} | 2 -- 1 file changed, 2 deletions(-) rename src/Automation.Tests/{PullRequestPlannerTests.cs => PullRequestPropertyTests.cs} (99%) diff --git a/src/Automation.Tests/PullRequestPlannerTests.cs b/src/Automation.Tests/PullRequestPropertyTests.cs similarity index 99% rename from src/Automation.Tests/PullRequestPlannerTests.cs rename to src/Automation.Tests/PullRequestPropertyTests.cs index 7aa0755dd..c77e2de62 100644 --- a/src/Automation.Tests/PullRequestPlannerTests.cs +++ b/src/Automation.Tests/PullRequestPropertyTests.cs @@ -2,9 +2,7 @@ // 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; From 82ab92459896572c613360e9d1881d8c18e02e31 Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Thu, 9 Jul 2026 09:12:27 -0700 Subject: [PATCH 05/16] Clone PR source repo when appending When updating an existing pull request in append mode, clone the configured PR source repo instead of always cloning upstream. This allows fork-backed PR branches to be updated from their actual source repository. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Automation/PullRequestManager.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Automation/PullRequestManager.cs b/src/Automation/PullRequestManager.cs index 83f627f7e..041c62bbf 100644 --- a/src/Automation/PullRequestManager.cs +++ b/src/Automation/PullRequestManager.cs @@ -82,15 +82,16 @@ public async Task CreateOrUpdateAsync( // 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; + bool appendToExistingPullRequest = existing is not null && updateStrategy == PullRequestUpdateStrategy.Append; + GitHubRepo pullRequestSourceRepo = fork ?? upstream; + GitHubRepo cloneRepo = appendToExistingPullRequest ? pullRequestSourceRepo : upstream; + string cloneBranch = appendToExistingPullRequest ? definition.Key : definition.TargetBranch; - _logger.LogInformation("Cloning {Url} branch '{Branch}'.", upstream.GetCloneUrl(), cloneBranch); + _logger.LogInformation("Cloning {Url} branch '{Branch}'.", cloneRepo.GetCloneUrl(), cloneBranch); using GitWorkspace workspace = await GitWorkspace.CloneAsync( _gitLogger, - upstream.GetAuthenticatedCloneUrl(token), + cloneRepo.GetAuthenticatedCloneUrl(token), cloneBranch, identity.AuthorName, identity.AuthorEmail, From b83d51a205c55fd274333665e5d7ff8cd9bddbdd Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Thu, 9 Jul 2026 09:18:39 -0700 Subject: [PATCH 06/16] Narrow Automation public API surface Expose internals to the test assembly and make planner-only models, operations, results, and helpers internal so the package only exposes caller-facing pull request automation types. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Automation/GitHub/GitHubRepo.cs | 2 +- .../Microsoft.DotNet.Automation.csproj | 4 +++ src/Automation/Models.cs | 32 +++++++++---------- src/Automation/Planner.cs | 2 +- 4 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/Automation/GitHub/GitHubRepo.cs b/src/Automation/GitHub/GitHubRepo.cs index e668b5a7d..d200dddbb 100644 --- a/src/Automation/GitHub/GitHubRepo.cs +++ b/src/Automation/GitHub/GitHubRepo.cs @@ -6,7 +6,7 @@ namespace Microsoft.DotNet.Automation.GitHub; public sealed record GitHubRepo(string Owner, string Name); -public static class GitHubRepoExtensions +internal static class GitHubRepoExtensions { public static Uri GetCloneUrl(this GitHubRepo repo) => new($"https://github.com/{repo.Owner}/{repo.Name}"); diff --git a/src/Automation/Microsoft.DotNet.Automation.csproj b/src/Automation/Microsoft.DotNet.Automation.csproj index 4f1410e43..2c592b8c6 100644 --- a/src/Automation/Microsoft.DotNet.Automation.csproj +++ b/src/Automation/Microsoft.DotNet.Automation.csproj @@ -20,4 +20,8 @@ + + + + diff --git a/src/Automation/Models.cs b/src/Automation/Models.cs index a96975671..42c834da9 100644 --- a/src/Automation/Models.cs +++ b/src/Automation/Models.cs @@ -11,7 +11,7 @@ public sealed record PullRequestDefinition( string TargetBranch, Func ApplyChanges); -public sealed record PullRequestState(string Key, string Title, string Body, string TargetBranch, string TreeHash); +internal sealed record PullRequestState(string Key, string Title, string Body, string TargetBranch, string TreeHash); public enum PullRequestUpdateStrategy { @@ -76,14 +76,14 @@ public sealed record PullRequestResult(PullRequestAction Action, Uri? Url); /// 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); +internal sealed record ExistingPullRequest(PullRequestState Content, int Number, Uri Url, IReadOnlyList Commits); /// /// The observed state of the branch a new pull request would be created from. /// When no pull request exists yet, its tree is the base we diff the desired /// tree against to decide whether there is anything to propose. /// -public sealed record TargetBranchState(string TreeHash); +internal sealed record TargetBranchState(string TreeHash); /// /// The automation's git identity, used to distinguish its own commits from foreign ones. @@ -93,20 +93,20 @@ 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); +internal 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, bool ForcePush) : 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; +internal interface IOperation; +internal sealed record PushCommitsOperation(string WorkspaceDirectory, string SourceBranch, bool ForcePush) : IOperation; +internal sealed record CreatePullRequestOperation(string Title, string Body, string SourceBranch, string TargetBranch) : IOperation; +internal sealed record UpdateTitleOperation(int Number, string Title) : IOperation; +internal sealed record UpdateBodyOperation(int Number, string Body) : IOperation; +internal sealed record UpdateBaseBranchOperation(int Number, string TargetBranch) : IOperation; // TODO: Use C# 15 unions after .NET 11's release -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; +internal interface IOperationResult; +internal sealed record CommitsPushed(string Branch, string FromSha, string ToSha, Uri Url) : IOperationResult; +internal sealed record PullRequestCreated(int Number, Uri Url) : IOperationResult; +internal sealed record TitleUpdated(int Number, string Title) : IOperationResult; +internal sealed record BodyUpdated(int Number, string Body) : IOperationResult; +internal sealed record BaseBranchUpdated(int Number, string TargetBranch) : IOperationResult; diff --git a/src/Automation/Planner.cs b/src/Automation/Planner.cs index 64cb1c0ad..8fbd549c5 100644 --- a/src/Automation/Planner.cs +++ b/src/Automation/Planner.cs @@ -4,7 +4,7 @@ namespace Microsoft.DotNet.Automation; -public static class Planner +internal static class Planner { public static IEnumerable Plan( string workspaceDirectory, From afc541775c5cd1d985a5d4e74c224f099932d088 Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Thu, 9 Jul 2026 09:24:52 -0700 Subject: [PATCH 07/16] Require XML docs for Automation public API Generate XML documentation for the Automation package and remove Arcade's CS1591 suppression so missing public API docs fail under TreatWarningsAsErrors. Add the currently missing public XML comments. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Automation/GitHub/GitHubRepo.cs | 5 +++++ src/Automation/IGitContext.cs | 12 ++++++++++++ .../Microsoft.DotNet.Automation.csproj | 6 ++++++ src/Automation/Models.cs | 16 ++++++++++++++++ 4 files changed, 39 insertions(+) diff --git a/src/Automation/GitHub/GitHubRepo.cs b/src/Automation/GitHub/GitHubRepo.cs index d200dddbb..1243d1e0c 100644 --- a/src/Automation/GitHub/GitHubRepo.cs +++ b/src/Automation/GitHub/GitHubRepo.cs @@ -4,6 +4,11 @@ namespace Microsoft.DotNet.Automation.GitHub; +/// +/// Identifies a GitHub repository by owner and name. +/// +/// The repository owner or organization. +/// The repository name. public sealed record GitHubRepo(string Owner, string Name); internal static class GitHubRepoExtensions diff --git a/src/Automation/IGitContext.cs b/src/Automation/IGitContext.cs index dc006f05c..abd10c5cd 100644 --- a/src/Automation/IGitContext.cs +++ b/src/Automation/IGitContext.cs @@ -4,8 +4,20 @@ namespace Microsoft.DotNet.Automation; +/// +/// Provides git operations available to a pull request definition while it applies changes. +/// public interface IGitContext { + /// + /// The local working directory where changes should be applied. + /// string WorkspaceDirectory { get; } + + /// + /// Commits the current workspace changes using the specified commit message. + /// + /// The commit message. + /// A token that cancels the commit operation. Task CommitAsync(string message, CancellationToken cancellationToken); } diff --git a/src/Automation/Microsoft.DotNet.Automation.csproj b/src/Automation/Microsoft.DotNet.Automation.csproj index 2c592b8c6..eae844358 100644 --- a/src/Automation/Microsoft.DotNet.Automation.csproj +++ b/src/Automation/Microsoft.DotNet.Automation.csproj @@ -8,6 +8,12 @@ enable + + true + + $(NoWarn.Replace(';1591', '')) + + true Declarative git automation library. diff --git a/src/Automation/Models.cs b/src/Automation/Models.cs index 42c834da9..4cbcb2954 100644 --- a/src/Automation/Models.cs +++ b/src/Automation/Models.cs @@ -4,6 +4,14 @@ namespace Microsoft.DotNet.Automation; +/// +/// Defines the desired pull request content and the changes that produce it. +/// +/// The source branch name used to identify and update the pull request. +/// The desired pull request title. +/// The desired pull request body. +/// The branch the pull request targets. +/// Applies the desired workspace changes before the automation commits them. public sealed record PullRequestDefinition( string Key, string Title, @@ -13,6 +21,9 @@ public sealed record PullRequestDefinition( internal sealed record PullRequestState(string Key, string Title, string Body, string TargetBranch, string TreeHash); +/// +/// Determines how commits are pushed when updating an existing pull request branch. +/// public enum PullRequestUpdateStrategy { /// @@ -26,6 +37,9 @@ public enum PullRequestUpdateStrategy Replace, } +/// +/// Determines how existing pull request commits from other authors are handled. +/// public enum ForeignCommitPolicy { /// @@ -88,6 +102,8 @@ internal sealed record TargetBranchState(string TreeHash); /// /// The automation's git identity, used to distinguish its own commits from foreign ones. /// +/// The git author name used for automation commits. +/// The git author email used for automation commits. public sealed record AutomationIdentity(string AuthorName, string AuthorEmail); /// From 9b6d4cc6ade71535799a7033772c15f61761c83c Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Fri, 10 Jul 2026 11:59:04 -0700 Subject: [PATCH 08/16] Add dependency injection support to Automation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../DependencyInjectionTests.cs | 77 +++++++++ .../Microsoft.DotNet.Automation.Tests.csproj | 1 + .../ProcessRunnerExtensionsTests.cs | 34 ++++ src/Automation/Git.cs | 60 ++----- src/Automation/GitContext.cs | 10 +- src/Automation/GitHub/GitHubRepoHost.cs | 11 +- src/Automation/GitWorkspace.cs | 11 +- src/Automation/IGitAccessTokenProvider.cs | 18 +++ src/Automation/IProcessRunner.cs | 35 ++++ .../Microsoft.DotNet.Automation.csproj | 1 + src/Automation/ProcessRunner.cs | 73 +++++++++ src/Automation/ProcessRunnerExtensions.cs | 33 ++++ ...stAutomationServiceCollectionExtensions.cs | 64 ++++++++ src/Automation/PullRequestManager.cs | 153 +++++++++++++----- src/Automation/README.md | 34 +++- .../StaticGitAccessTokenProvider.cs | 30 ++++ 16 files changed, 537 insertions(+), 108 deletions(-) create mode 100644 src/Automation.Tests/DependencyInjectionTests.cs create mode 100644 src/Automation.Tests/ProcessRunnerExtensionsTests.cs create mode 100644 src/Automation/IGitAccessTokenProvider.cs create mode 100644 src/Automation/IProcessRunner.cs create mode 100644 src/Automation/ProcessRunner.cs create mode 100644 src/Automation/ProcessRunnerExtensions.cs create mode 100644 src/Automation/PullRequestAutomationServiceCollectionExtensions.cs create mode 100644 src/Automation/StaticGitAccessTokenProvider.cs diff --git a/src/Automation.Tests/DependencyInjectionTests.cs b/src/Automation.Tests/DependencyInjectionTests.cs new file mode 100644 index 000000000..90fe34ef4 --- /dev/null +++ b/src/Automation.Tests/DependencyInjectionTests.cs @@ -0,0 +1,77 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.DotNet.Automation.Tests; + +[TestClass] +public sealed class DependencyInjectionTests +{ + [TestMethod] + public void PullRequestManager_ResolvesWithDefaultServices() + { + ServiceCollection services = new(); + services.AddPullRequestAutomation( + new AutomationIdentity("bot", "bot@example.com"), + "token"); + + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + + PullRequestManager manager = serviceProvider.GetRequiredService(); + + Assert.IsNotNull(manager); + } + + [TestMethod] + public void PullRequestManager_ResolvesWithCustomServices() + { + ServiceCollection services = new(); + bool processRunnerResolved = false; + bool accessTokenProviderResolved = false; + services.AddSingleton(_ => + { + processRunnerResolved = true; + return new StubProcessRunner(); + }); + services.AddSingleton(_ => + { + accessTokenProviderResolved = true; + return new StaticGitAccessTokenProvider("token"); + }); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(new AutomationIdentity("bot", "bot@example.com")); + services.AddSingleton(); + + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + + PullRequestManager manager = serviceProvider.GetRequiredService(); + + Assert.IsNotNull(manager); + Assert.IsTrue(processRunnerResolved); + Assert.IsTrue(accessTokenProviderResolved); + } + + [TestMethod] + public void PullRequestManager_CanBeCreatedWithoutDependencyInjection() + { + PullRequestManager manager = new( + "token", + new AutomationIdentity("bot", "bot@example.com")); + + Assert.IsNotNull(manager); + } + + private sealed class StubProcessRunner : IProcessRunner + { + public Task RunAsync( + string? workingDirectory, + string fileName, + IEnumerable arguments, + CancellationToken cancellationToken) => + Task.FromResult(new ProcessResult(0, string.Empty, string.Empty)); + } +} diff --git a/src/Automation.Tests/Microsoft.DotNet.Automation.Tests.csproj b/src/Automation.Tests/Microsoft.DotNet.Automation.Tests.csproj index ae879a7c1..ed6663f45 100644 --- a/src/Automation.Tests/Microsoft.DotNet.Automation.Tests.csproj +++ b/src/Automation.Tests/Microsoft.DotNet.Automation.Tests.csproj @@ -17,6 +17,7 @@ + diff --git a/src/Automation.Tests/ProcessRunnerExtensionsTests.cs b/src/Automation.Tests/ProcessRunnerExtensionsTests.cs new file mode 100644 index 000000000..fdc8907d0 --- /dev/null +++ b/src/Automation.Tests/ProcessRunnerExtensionsTests.cs @@ -0,0 +1,34 @@ +// 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; + +[TestClass] +public sealed class ProcessRunnerExtensionsTests +{ + [TestMethod] + public async Task RunAsyncExtension_UsesCurrentWorkingDirectory() + { + StubProcessRunner processRunner = new(new ProcessResult(0, string.Empty, string.Empty)); + + await processRunner.RunAsync("git", ["--version"], CancellationToken.None); + + Assert.IsNull(processRunner.WorkingDirectory); + } + + private sealed class StubProcessRunner(ProcessResult result) : IProcessRunner + { + public string? WorkingDirectory { get; private set; } + + public Task RunAsync( + string? workingDirectory, + string fileName, + IEnumerable arguments, + CancellationToken cancellationToken) + { + WorkingDirectory = workingDirectory; + return Task.FromResult(result); + } + } +} diff --git a/src/Automation/Git.cs b/src/Automation/Git.cs index 517d0ec17..ca3f4304f 100644 --- a/src/Automation/Git.cs +++ b/src/Automation/Git.cs @@ -2,65 +2,27 @@ // 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 +internal sealed class Git(IProcessRunner processRunner, ILogger logger) { - public static async Task RunAsync( - ILogger logger, + public async Task RunAsync( 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; + ProcessResult result = await processRunner.RunAsync( + workingDirectory, + fileName: "git", + args, + cancellationToken); + string output = result.StandardOutput; + string error = result.StandardError; // git writes progress and other human-readable messages to stderr even on success. if (!string.IsNullOrWhiteSpace(error)) @@ -85,10 +47,10 @@ public static async Task RunAsync( ); } - if (process.ExitCode != 0) + if (result.ExitCode != 0) { throw new InvalidOperationException( - $"Git command failed with exit code {process.ExitCode}.{Environment.NewLine}{error}"); + $"Git command failed with exit code {result.ExitCode}.{Environment.NewLine}{error}"); } return output.Trim(); diff --git a/src/Automation/GitContext.cs b/src/Automation/GitContext.cs index 47453f235..85d93b317 100644 --- a/src/Automation/GitContext.cs +++ b/src/Automation/GitContext.cs @@ -6,7 +6,7 @@ namespace Microsoft.DotNet.Automation; -internal sealed class GitContext(string workspaceDirectory, ILogger logger) : IGitContext +internal sealed class GitContext(string workspaceDirectory, Git git, ILogger logger) : IGitContext { public string WorkspaceDirectory { get; } = workspaceDirectory; @@ -14,17 +14,17 @@ public async Task CommitAsync(string message, CancellationToken cancellationToke { ArgumentException.ThrowIfNullOrWhiteSpace(message); - string status = await Git.RunAsync(logger, secret: null, WorkspaceDirectory, cancellationToken, "status", "--porcelain"); + string status = await git.RunAsync(secret: null, WorkspaceDirectory, cancellationToken, "status", "--porcelain"); if (string.IsNullOrWhiteSpace(status)) { logger.LogInformation("No changes to commit; working tree is clean."); return; } - await Git.RunAsync(logger, secret: null, WorkspaceDirectory, cancellationToken, "add", "--all"); - await Git.RunAsync(logger, secret: null, WorkspaceDirectory, cancellationToken, "commit", "--message", message); + await git.RunAsync(secret: null, WorkspaceDirectory, cancellationToken, "add", "--all"); + await git.RunAsync(secret: null, WorkspaceDirectory, cancellationToken, "commit", "--message", message); - string commit = await Git.RunAsync(logger, secret: null, WorkspaceDirectory, cancellationToken, "rev-parse", "HEAD"); + string commit = await git.RunAsync(secret: null, WorkspaceDirectory, cancellationToken, "rev-parse", "HEAD"); logger.LogInformation("Committed changes as {Commit}: \"{Message}\".", commit, message); } } diff --git a/src/Automation/GitHub/GitHubRepoHost.cs b/src/Automation/GitHub/GitHubRepoHost.cs index 82fdfbf68..3f73c2076 100644 --- a/src/Automation/GitHub/GitHubRepoHost.cs +++ b/src/Automation/GitHub/GitHubRepoHost.cs @@ -12,13 +12,12 @@ internal sealed class GitHubRepoHost( GitHubRepo sourceRepo, string token, IGitHubClient client, - ILoggerFactory loggerFactory + ILoggerFactory loggerFactory, + Git git ) : 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(); @@ -114,13 +113,13 @@ private async Task PushAsync(PushCommitsOperation operation, Canc string dir = operation.WorkspaceDirectory; string remoteRef = $"refs/heads/{branch}"; - string lsRemote = await Git.RunAsync(_gitLogger, token, dir, cancellationToken, ["ls-remote", "--heads", authUrl, remoteRef]); + string lsRemote = await git.RunAsync(token, dir, cancellationToken, ["ls-remote", "--heads", authUrl, remoteRef]); string? existingLine = lsRemote .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) .FirstOrDefault(line => line.EndsWith($"\t{remoteRef}", StringComparison.Ordinal)); string fromSha = existingLine is null ? string.Empty : existingLine.Split('\t')[0]; - string toSha = await Git.RunAsync(_gitLogger, secret: null, dir, cancellationToken, "rev-parse", "HEAD"); + string toSha = await git.RunAsync(secret: null, dir, cancellationToken, "rev-parse", "HEAD"); bool forcePush = operation.ForcePush; string[] pushArgs = forcePush @@ -131,7 +130,7 @@ private async Task PushAsync(PushCommitsOperation operation, Canc "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); + await git.RunAsync(token, dir, cancellationToken, pushArgs); Uri commitUrl = sourceRepo.GetCommitUrl(toSha); _logger.LogInformation( diff --git a/src/Automation/GitWorkspace.cs b/src/Automation/GitWorkspace.cs index de655ce18..d8b91a631 100644 --- a/src/Automation/GitWorkspace.cs +++ b/src/Automation/GitWorkspace.cs @@ -11,6 +11,7 @@ internal sealed class GitWorkspace(string directory, ILogger logger) : IDisposab public string WorkingDirectory { get; } = directory; public static async Task CloneAsync( + Git git, ILogger logger, Uri cloneUrl, string branch, @@ -23,12 +24,12 @@ public static async Task CloneAsync( // 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); + var runGit = async (string[] args, string? directory = null) => + await git.RunAsync(secret, directory, ct, args); try { - await git([ + await runGit([ "clone", "--filter=blob:none", "--single-branch", @@ -39,8 +40,8 @@ await git([ directory, ]); - await git(["config", "user.name", authorName], directory); - await git(["config", "user.email", authorEmail], directory); + await runGit(["config", "user.name", authorName], directory); + await runGit(["config", "user.email", authorEmail], directory); } catch (Exception exception) when (Directory.Exists(directory)) { diff --git a/src/Automation/IGitAccessTokenProvider.cs b/src/Automation/IGitAccessTokenProvider.cs new file mode 100644 index 000000000..1812804b1 --- /dev/null +++ b/src/Automation/IGitAccessTokenProvider.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.DotNet.Automation; + +/// +/// Provides access tokens for repository host operations. +/// +public interface IGitAccessTokenProvider +{ + /// + /// Gets an access token that is valid for the current operation. + /// + /// A token that cancels token acquisition. + /// An access token. + ValueTask GetTokenAsync(CancellationToken cancellationToken); +} diff --git a/src/Automation/IProcessRunner.cs b/src/Automation/IProcessRunner.cs new file mode 100644 index 000000000..b432ee86a --- /dev/null +++ b/src/Automation/IProcessRunner.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.DotNet.Automation; + +/// +/// Runs external processes and captures their output. +/// +public interface IProcessRunner +{ + /// + /// Runs an external process. + /// + /// + /// The process working directory, or to use the current directory. + /// + /// The executable to run. + /// The arguments passed to the executable. + /// A token that cancels the process. + /// The process exit code and captured output. + Task RunAsync( + string? workingDirectory, + string fileName, + IEnumerable arguments, + CancellationToken cancellationToken); +} + +/// +/// The result of running an external process. +/// +/// The process exit code. +/// The captured standard output. +/// The captured standard error. +public sealed record ProcessResult(int ExitCode, string StandardOutput, string StandardError); diff --git a/src/Automation/Microsoft.DotNet.Automation.csproj b/src/Automation/Microsoft.DotNet.Automation.csproj index eae844358..280b6f9e1 100644 --- a/src/Automation/Microsoft.DotNet.Automation.csproj +++ b/src/Automation/Microsoft.DotNet.Automation.csproj @@ -22,6 +22,7 @@ + diff --git a/src/Automation/ProcessRunner.cs b/src/Automation/ProcessRunner.cs new file mode 100644 index 000000000..10b2ccf05 --- /dev/null +++ b/src/Automation/ProcessRunner.cs @@ -0,0 +1,73 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Automation; + +/// +/// Runs external processes using . +/// +/// The logger used to report process cleanup failures. +public sealed class ProcessRunner(ILogger logger) : IProcessRunner +{ + /// + public async Task RunAsync( + string? workingDirectory, + string fileName, + IEnumerable arguments, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fileName); + ArgumentNullException.ThrowIfNull(arguments); + + ProcessStartInfo startInfo = new(fileName) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + if (workingDirectory is not null) + { + startInfo.WorkingDirectory = workingDirectory; + } + + foreach (string argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using Process process = Process.Start(startInfo) + ?? throw new InvalidOperationException($"Failed to start process '{startInfo.FileName}'."); + + Task outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + Task errorTask = process.StandardError.ReadToEndAsync(cancellationToken); + + try + { + await process.WaitForExitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + try + { + process.Kill(entireProcessTree: true); + } + catch (Exception exception) + { + // The process may have already exited. + logger.LogWarning(exception, "Failed to kill process after cancellation."); + } + + throw; + } + + return new ProcessResult( + process.ExitCode, + await outputTask, + await errorTask); + } +} diff --git a/src/Automation/ProcessRunnerExtensions.cs b/src/Automation/ProcessRunnerExtensions.cs new file mode 100644 index 000000000..993d8effe --- /dev/null +++ b/src/Automation/ProcessRunnerExtensions.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.DotNet.Automation; + +/// +/// Convenience methods for running external processes. +/// +public static class ProcessRunnerExtensions +{ + /// + /// Runs an external process in the current working directory. + /// + /// The process runner. + /// The executable to run. + /// The arguments passed to the executable. + /// A token that cancels the process. + /// The process exit code and captured output. + public static Task RunAsync( + this IProcessRunner processRunner, + string fileName, + IEnumerable arguments, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(processRunner); + return processRunner.RunAsync( + workingDirectory: null, + fileName, + arguments, + cancellationToken); + } +} diff --git a/src/Automation/PullRequestAutomationServiceCollectionExtensions.cs b/src/Automation/PullRequestAutomationServiceCollectionExtensions.cs new file mode 100644 index 000000000..cce0438d3 --- /dev/null +++ b/src/Automation/PullRequestAutomationServiceCollectionExtensions.cs @@ -0,0 +1,64 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.DotNet.Automation; + +/// +/// Registers services for declarative pull request automation. +/// +public static class PullRequestAutomationServiceCollectionExtensions +{ + /// + /// Registers pull request automation using a fixed access token and the default services. + /// + /// The service collection. + /// The git identity used for automation commits. + /// The fixed git access token. + /// The service collection. + public static IServiceCollection AddPullRequestAutomation( + this IServiceCollection services, + AutomationIdentity identity, + string token) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(identity); + + services.TryAddSingleton( + new StaticGitAccessTokenProvider(token)); + + return services.AddPullRequestAutomation(identity); + } + + /// + /// Registers pull request automation using caller-provided services. + /// + /// The service collection. + /// The git identity used for automation commits. + /// The service collection. + /// + /// An must also be registered. A caller-provided + /// registration replaces the default . + /// + public static IServiceCollection AddPullRequestAutomation( + this IServiceCollection services, + AutomationIdentity identity) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(identity); + + services.TryAddSingleton(NullLoggerFactory.Instance); + services.TryAddSingleton>(provider => + provider.GetRequiredService().CreateLogger()); + services.TryAddSingleton(identity); + services.TryAddSingleton(); + services.TryAddSingleton(); + + return services; + } +} diff --git a/src/Automation/PullRequestManager.cs b/src/Automation/PullRequestManager.cs index 041c62bbf..f75831d5a 100644 --- a/src/Automation/PullRequestManager.cs +++ b/src/Automation/PullRequestManager.cs @@ -14,54 +14,124 @@ namespace Microsoft.DotNet.Automation; /// 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) +public sealed class PullRequestManager { - private readonly ILogger _logger = - (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + private readonly IGitAccessTokenProvider _accessTokenProvider; + private readonly AutomationIdentity _identity; + private readonly Git _git; + private readonly ILogger _gitLogger; + private readonly ILoggerFactory _loggerFactory; + private readonly ILogger _logger; - private readonly ILogger _gitLogger = - (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(nameof(Git)); + /// + /// Creates a manager with caller-provided services. + /// + /// Provides tokens for repository host operations. + /// The git identity used for the automation's commits. + /// Runs the git processes used during reconciliation. + /// Creates the loggers used to trace the reconciliation. + public PullRequestManager( + IGitAccessTokenProvider accessTokenProvider, + AutomationIdentity identity, + IProcessRunner processRunner, + ILoggerFactory loggerFactory) + { + ArgumentNullException.ThrowIfNull(accessTokenProvider); + ArgumentNullException.ThrowIfNull(identity); + ArgumentNullException.ThrowIfNull(processRunner); + ArgumentNullException.ThrowIfNull(loggerFactory); - private readonly IRepoHost _host = new GitHubRepoHost( - targetRepo: upstream, - sourceRepo: fork ?? upstream, - token, - CreateClient(token), - loggerFactory ?? NullLoggerFactory.Instance); + _accessTokenProvider = accessTokenProvider; + _identity = identity; + _loggerFactory = loggerFactory; + _logger = loggerFactory.CreateLogger(); + _gitLogger = loggerFactory.CreateLogger(nameof(Git)); + _git = new Git(processRunner, _gitLogger); + } + + /// + /// Creates a manager with a caller-provided token provider and the default + /// . + /// + /// Provides tokens for repository host operations. + /// The git identity used for the automation's commits. + /// + /// Creates the loggers used to trace the reconciliation. Omit (or pass + /// ) to disable logging. + /// + public PullRequestManager( + IGitAccessTokenProvider accessTokenProvider, + AutomationIdentity identity, + ILoggerFactory? loggerFactory = null) + : this( + accessTokenProvider, + identity, + CreateDefaultProcessRunner(loggerFactory), + loggerFactory ?? NullLoggerFactory.Instance) + { + } + + /// + /// Creates a manager with a fixed token and the default . + /// + /// An access token with permission to push and open pull requests. + /// The git identity used for the automation's commits. + /// + /// Creates the loggers used to trace the reconciliation. Omit (or pass + /// ) to disable logging. + /// + public PullRequestManager( + string token, + AutomationIdentity identity, + ILoggerFactory? loggerFactory = null) + : this( + new StaticGitAccessTokenProvider(token), + identity, + loggerFactory) + { + } /// /// Creates the pull request if it does not exist, or updates it to match /// the definition if it has drifted. /// + /// The desired pull request state and changes. + /// The repository the pull request is opened against. + /// + /// The repository commits are pushed to. Omit (or pass ) to + /// push directly to without a fork. + /// + /// How an existing pull request branch is updated. + /// How commits from other authors are handled. + /// A token that cancels the reconciliation. public async Task CreateOrUpdateAsync( PullRequestDefinition definition, + GitHubRepo upstream, + GitHubRepo? fork = null, PullRequestUpdateStrategy updateStrategy = PullRequestUpdateStrategy.Append, ForeignCommitPolicy onForeignCommits = ForeignCommitPolicy.Proceed, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(definition); + ArgumentNullException.ThrowIfNull(upstream); + + string token = await _accessTokenProvider.GetTokenAsync(cancellationToken); + + IRepoHost host = new GitHubRepoHost( + targetRepo: upstream, + sourceRepo: fork ?? upstream, + token, + CreateClient(token), + _loggerFactory, + _git); + _logger.LogInformation( "Creating or updating pull request for branch '{Key}' into '{TargetBranch}'.", definition.Key, definition.TargetBranch); // Fetch the existing pull request first: it decides which branch we build on. - ExistingPullRequest? existing = await _host.GetPullRequest(definition.Key, cancellationToken); + ExistingPullRequest? existing = await host.GetPullRequest(definition.Key, cancellationToken); if (existing is null) { @@ -90,17 +160,18 @@ public async Task CreateOrUpdateAsync( _logger.LogInformation("Cloning {Url} branch '{Branch}'.", cloneRepo.GetCloneUrl(), cloneBranch); using GitWorkspace workspace = await GitWorkspace.CloneAsync( + _git, _gitLogger, cloneRepo.GetAuthenticatedCloneUrl(token), cloneBranch, - identity.AuthorName, - identity.AuthorEmail, + _identity.AuthorName, + _identity.AuthorEmail, cancellationToken); - GitContext gitContext = new(workspace.WorkingDirectory, _gitLogger); + GitContext gitContext = new(workspace.WorkingDirectory, _git, _gitLogger); - string clonedCommit = await Git.RunAsync( - _gitLogger, secret: null, workspace.WorkingDirectory, cancellationToken, "rev-parse", "HEAD"); + string clonedCommit = await _git.RunAsync( + secret: null, workspace.WorkingDirectory, cancellationToken, "rev-parse", "HEAD"); _logger.LogInformation( "Cloned branch '{Branch}' at commit {Commit} into {Directory}.", @@ -111,8 +182,7 @@ public async Task CreateOrUpdateAsync( // 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, + string targetBranchTreeHash = await _git.RunAsync( secret: null, workspace.WorkingDirectory, cancellationToken, @@ -123,8 +193,7 @@ public async Task CreateOrUpdateAsync( await definition.ApplyChanges(gitContext, cancellationToken); await gitContext.CommitAsync(definition.Title, cancellationToken); - string treeHash = await Git.RunAsync( - _gitLogger, + string treeHash = await _git.RunAsync( secret: null, workspace.WorkingDirectory, cancellationToken, @@ -142,7 +211,7 @@ public async Task CreateOrUpdateAsync( IOperation[] operations = Planner.Plan( workspace.WorkingDirectory, - identity, + _identity, desired, targetBranch, existing, @@ -163,7 +232,7 @@ public async Task CreateOrUpdateAsync( operationsString); } - IReadOnlyList results = await _host.ExecuteAsync(operations, cancellationToken); + IReadOnlyList results = await host.ExecuteAsync(operations, cancellationToken); // A create result carries its own URL — trust it directly. PullRequestCreated? created = results.OfType().SingleOrDefault(); @@ -184,4 +253,10 @@ private static IGitHubClient CreateClient(string token) var credentials = new Credentials(token); return new GitHubClient(productHeaderValue) { Credentials = credentials }; } + + private static IProcessRunner CreateDefaultProcessRunner(ILoggerFactory? loggerFactory) + { + ILoggerFactory effectiveLoggerFactory = loggerFactory ?? NullLoggerFactory.Instance; + return new ProcessRunner(effectiveLoggerFactory.CreateLogger()); + } } diff --git a/src/Automation/README.md b/src/Automation/README.md index 5692b9ee7..ee2343f8d 100644 --- a/src/Automation/README.md +++ b/src/Automation/README.md @@ -28,11 +28,7 @@ using Microsoft.DotNet.Automation.GitHub; // Instantiate the pull request manager. var pullRequestManager = new PullRequestManager( token: Environment.GetEnvironmentVariable("GITHUB_TOKEN") ?? "", - upstream: new GitHubRepo("dotnet", "example"), identity: new AutomationIdentity("bot", "bot@example.com"), - // Optional args: - // To submit the PR from a fork: - // fork: new GitHubRepo("bot-account", "example"), // Microsoft.Extensions.Logging support: // loggerFactory: ... ); @@ -59,6 +55,10 @@ var pullRequest = new PullRequestDefinition( // request if one is already open. PullRequestResult result = await pullRequestManager.CreateOrUpdateAsync( definition: pullRequest, + upstream: new GitHubRepo("dotnet", "example"), + + // To submit the PR from a fork: + // fork: new GitHubRepo("bot-account", "example"), // Update strategies: // - Append: for an existing pull request, take the source branch and add new @@ -79,3 +79,29 @@ PullRequestResult result = await pullRequestManager.CreateOrUpdateAsync( cancellationToken: ct ); ``` + +### Dependency injection + +Register pull request automation with a git identity and fixed token. The +resulting `PullRequestManager` can be injected into application services: + +```csharp +using Microsoft.Extensions.DependencyInjection; + +services.AddPullRequestAutomation( + identity: new AutomationIdentity("bot", "bot@example.com"), + token: Environment.GetEnvironmentVariable("GITHUB_TOKEN") ?? ""); +``` + +For more control, register each dependency directly: + +```csharp +services.AddLogging(builder => builder.AddSimpleConsole()); +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(new AutomationIdentity("bot", "bot@example.com")); +services.AddSingleton(); +``` + +`IGitAccessTokenProvider` is queried before each operation, so implementations +can refresh credentials such as GitHub App installation tokens. diff --git a/src/Automation/StaticGitAccessTokenProvider.cs b/src/Automation/StaticGitAccessTokenProvider.cs new file mode 100644 index 000000000..58381b280 --- /dev/null +++ b/src/Automation/StaticGitAccessTokenProvider.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.DotNet.Automation; + +/// +/// Provides a fixed access token, such as a GitHub or Azure DevOps personal access token. +/// +public sealed class StaticGitAccessTokenProvider : IGitAccessTokenProvider +{ + private readonly string _token; + + /// + /// Creates a provider for a fixed access token. + /// + /// The access token value. + public StaticGitAccessTokenProvider(string token) + { + ArgumentException.ThrowIfNullOrWhiteSpace(token); + _token = token; + } + + /// + public ValueTask GetTokenAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.FromResult(_token); + } +} From aaa6c294a889938807cc63883cddb17442a787c0 Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Fri, 10 Jul 2026 13:05:17 -0700 Subject: [PATCH 09/16] Validate pull request key names Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Automation/Models.cs | 45 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/src/Automation/Models.cs b/src/Automation/Models.cs index 4cbcb2954..106bc4b4d 100644 --- a/src/Automation/Models.cs +++ b/src/Automation/Models.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Text.RegularExpressions; + namespace Microsoft.DotNet.Automation; /// @@ -12,12 +14,51 @@ namespace Microsoft.DotNet.Automation; /// The desired pull request body. /// The branch the pull request targets. /// Applies the desired workspace changes before the automation commits them. -public sealed record PullRequestDefinition( +public sealed partial record PullRequestDefinition( string Key, string Title, string Body, string TargetBranch, - Func ApplyChanges); + Func ApplyChanges) +{ + private string _key = ValidateKey(Key); + + /// + /// The source branch name used to identify and update the pull request. + /// + public string Key + { + get => _key; + init => _key = ValidateKey(value); + } + + private static string ValidateKey(string key) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key, nameof(Key)); + + bool hasValidComponents = key + .Split('/') + .All(component => + ValidKeyComponentRegex.IsMatch(component) + && !component.EndsWith(".lock", StringComparison.Ordinal)); + + if (key.StartsWith('-') || !hasValidComponents) + { + throw new ArgumentException( + $"'{key}' is not a valid pull request key. Use slash-separated components containing " + + "ASCII letters, digits, underscores, dashes, and periods. Periods must separate non-empty " + + "groups, components cannot end in '.lock', and the key cannot start with a dash.", + nameof(Key)); + } + + return key; + } + + // Matches one slash-separated component containing ASCII letters, digits, underscores, + // and dashes, with periods allowed only between non-empty groups. + [GeneratedRegex(@"^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$", RegexOptions.CultureInvariant)] + private static partial Regex ValidKeyComponentRegex { get; } +} internal sealed record PullRequestState(string Key, string Title, string Body, string TargetBranch, string TreeHash); From 4f1f17124b5a4c3f238b61bb5aab807e225014ae Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Fri, 10 Jul 2026 13:13:52 -0700 Subject: [PATCH 10/16] Rename Automation library to GitAutomation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Microsoft.DotNet.DockerTools.slnx | 4 ++-- .../DependencyInjectionTests.cs | 2 +- .../Microsoft.DotNet.GitAutomation.Tests.csproj} | 2 +- .../ProcessRunnerExtensionsTests.cs | 2 +- .../PullRequestPropertyTests.cs | 2 +- src/{Automation => GitAutomation}/Git.cs | 2 +- src/{Automation => GitAutomation}/GitContext.cs | 2 +- src/{Automation => GitAutomation}/GitHub/GitHubRepo.cs | 2 +- .../GitHub/GitHubRepoHost.cs | 2 +- src/{Automation => GitAutomation}/GitWorkspace.cs | 2 +- .../IGitAccessTokenProvider.cs | 2 +- src/{Automation => GitAutomation}/IGitContext.cs | 2 +- src/{Automation => GitAutomation}/IProcessRunner.cs | 2 +- src/{Automation => GitAutomation}/IRepoHost.cs | 2 +- .../Microsoft.DotNet.GitAutomation.csproj} | 2 +- src/{Automation => GitAutomation}/Models.cs | 2 +- src/{Automation => GitAutomation}/Planner.cs | 2 +- src/{Automation => GitAutomation}/ProcessRunner.cs | 2 +- .../ProcessRunnerExtensions.cs | 2 +- .../PullRequestAutomationServiceCollectionExtensions.cs | 2 +- src/{Automation => GitAutomation}/PullRequestManager.cs | 6 +++--- src/{Automation => GitAutomation}/README.md | 8 ++++---- .../StaticGitAccessTokenProvider.cs | 2 +- 23 files changed, 29 insertions(+), 29 deletions(-) rename src/{Automation.Tests => GitAutomation.Tests}/DependencyInjectionTests.cs (98%) rename src/{Automation.Tests/Microsoft.DotNet.Automation.Tests.csproj => GitAutomation.Tests/Microsoft.DotNet.GitAutomation.Tests.csproj} (89%) rename src/{Automation.Tests => GitAutomation.Tests}/ProcessRunnerExtensionsTests.cs (95%) rename src/{Automation.Tests => GitAutomation.Tests}/PullRequestPropertyTests.cs (99%) rename src/{Automation => GitAutomation}/Git.cs (97%) rename src/{Automation => GitAutomation}/GitContext.cs (96%) rename src/{Automation => GitAutomation}/GitHub/GitHubRepo.cs (95%) rename src/{Automation => GitAutomation}/GitHub/GitHubRepoHost.cs (99%) rename src/{Automation => GitAutomation}/GitWorkspace.cs (98%) rename src/{Automation => GitAutomation}/IGitAccessTokenProvider.cs (93%) rename src/{Automation => GitAutomation}/IGitContext.cs (95%) rename src/{Automation => GitAutomation}/IProcessRunner.cs (97%) rename src/{Automation => GitAutomation}/IRepoHost.cs (91%) rename src/{Automation/Microsoft.DotNet.Automation.csproj => GitAutomation/Microsoft.DotNet.GitAutomation.csproj} (94%) rename src/{Automation => GitAutomation}/Models.cs (99%) rename src/{Automation => GitAutomation}/Planner.cs (98%) rename src/{Automation => GitAutomation}/ProcessRunner.cs (98%) rename src/{Automation => GitAutomation}/ProcessRunnerExtensions.cs (96%) rename src/{Automation => GitAutomation}/PullRequestAutomationServiceCollectionExtensions.cs (98%) rename src/{Automation => GitAutomation}/PullRequestManager.cs (98%) rename src/{Automation => GitAutomation}/README.md (95%) rename src/{Automation => GitAutomation}/StaticGitAccessTokenProvider.cs (95%) diff --git a/Microsoft.DotNet.DockerTools.slnx b/Microsoft.DotNet.DockerTools.slnx index 3e85015bc..36bb37e88 100644 --- a/Microsoft.DotNet.DockerTools.slnx +++ b/Microsoft.DotNet.DockerTools.slnx @@ -7,8 +7,8 @@ - - + + diff --git a/src/Automation.Tests/DependencyInjectionTests.cs b/src/GitAutomation.Tests/DependencyInjectionTests.cs similarity index 98% rename from src/Automation.Tests/DependencyInjectionTests.cs rename to src/GitAutomation.Tests/DependencyInjectionTests.cs index 90fe34ef4..99bad789d 100644 --- a/src/Automation.Tests/DependencyInjectionTests.cs +++ b/src/GitAutomation.Tests/DependencyInjectionTests.cs @@ -6,7 +6,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -namespace Microsoft.DotNet.Automation.Tests; +namespace Microsoft.DotNet.GitAutomation.Tests; [TestClass] public sealed class DependencyInjectionTests diff --git a/src/Automation.Tests/Microsoft.DotNet.Automation.Tests.csproj b/src/GitAutomation.Tests/Microsoft.DotNet.GitAutomation.Tests.csproj similarity index 89% rename from src/Automation.Tests/Microsoft.DotNet.Automation.Tests.csproj rename to src/GitAutomation.Tests/Microsoft.DotNet.GitAutomation.Tests.csproj index ed6663f45..86e5e2976 100644 --- a/src/Automation.Tests/Microsoft.DotNet.Automation.Tests.csproj +++ b/src/GitAutomation.Tests/Microsoft.DotNet.GitAutomation.Tests.csproj @@ -21,7 +21,7 @@ - + diff --git a/src/Automation.Tests/ProcessRunnerExtensionsTests.cs b/src/GitAutomation.Tests/ProcessRunnerExtensionsTests.cs similarity index 95% rename from src/Automation.Tests/ProcessRunnerExtensionsTests.cs rename to src/GitAutomation.Tests/ProcessRunnerExtensionsTests.cs index fdc8907d0..6485536f8 100644 --- a/src/Automation.Tests/ProcessRunnerExtensionsTests.cs +++ b/src/GitAutomation.Tests/ProcessRunnerExtensionsTests.cs @@ -2,7 +2,7 @@ // 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; +namespace Microsoft.DotNet.GitAutomation.Tests; [TestClass] public sealed class ProcessRunnerExtensionsTests diff --git a/src/Automation.Tests/PullRequestPropertyTests.cs b/src/GitAutomation.Tests/PullRequestPropertyTests.cs similarity index 99% rename from src/Automation.Tests/PullRequestPropertyTests.cs rename to src/GitAutomation.Tests/PullRequestPropertyTests.cs index c77e2de62..79f119d23 100644 --- a/src/Automation.Tests/PullRequestPropertyTests.cs +++ b/src/GitAutomation.Tests/PullRequestPropertyTests.cs @@ -4,7 +4,7 @@ using CsCheck; -namespace Microsoft.DotNet.Automation.Tests; +namespace Microsoft.DotNet.GitAutomation.Tests; [TestClass] public sealed class PullRequestPlannerTests diff --git a/src/Automation/Git.cs b/src/GitAutomation/Git.cs similarity index 97% rename from src/Automation/Git.cs rename to src/GitAutomation/Git.cs index ca3f4304f..c56e8f132 100644 --- a/src/Automation/Git.cs +++ b/src/GitAutomation/Git.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.Logging; -namespace Microsoft.DotNet.Automation; +namespace Microsoft.DotNet.GitAutomation; internal sealed class Git(IProcessRunner processRunner, ILogger logger) { diff --git a/src/Automation/GitContext.cs b/src/GitAutomation/GitContext.cs similarity index 96% rename from src/Automation/GitContext.cs rename to src/GitAutomation/GitContext.cs index 85d93b317..dbcf9a567 100644 --- a/src/Automation/GitContext.cs +++ b/src/GitAutomation/GitContext.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.Logging; -namespace Microsoft.DotNet.Automation; +namespace Microsoft.DotNet.GitAutomation; internal sealed class GitContext(string workspaceDirectory, Git git, ILogger logger) : IGitContext { diff --git a/src/Automation/GitHub/GitHubRepo.cs b/src/GitAutomation/GitHub/GitHubRepo.cs similarity index 95% rename from src/Automation/GitHub/GitHubRepo.cs rename to src/GitAutomation/GitHub/GitHubRepo.cs index 1243d1e0c..f2e9c3e38 100644 --- a/src/Automation/GitHub/GitHubRepo.cs +++ b/src/GitAutomation/GitHub/GitHubRepo.cs @@ -2,7 +2,7 @@ // 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; +namespace Microsoft.DotNet.GitAutomation.GitHub; /// /// Identifies a GitHub repository by owner and name. diff --git a/src/Automation/GitHub/GitHubRepoHost.cs b/src/GitAutomation/GitHub/GitHubRepoHost.cs similarity index 99% rename from src/Automation/GitHub/GitHubRepoHost.cs rename to src/GitAutomation/GitHub/GitHubRepoHost.cs index 3f73c2076..2d9fca1b9 100644 --- a/src/Automation/GitHub/GitHubRepoHost.cs +++ b/src/GitAutomation/GitHub/GitHubRepoHost.cs @@ -5,7 +5,7 @@ using Microsoft.Extensions.Logging; using Octokit; -namespace Microsoft.DotNet.Automation.GitHub; +namespace Microsoft.DotNet.GitAutomation.GitHub; internal sealed class GitHubRepoHost( GitHubRepo targetRepo, diff --git a/src/Automation/GitWorkspace.cs b/src/GitAutomation/GitWorkspace.cs similarity index 98% rename from src/Automation/GitWorkspace.cs rename to src/GitAutomation/GitWorkspace.cs index d8b91a631..f134e0c39 100644 --- a/src/Automation/GitWorkspace.cs +++ b/src/GitAutomation/GitWorkspace.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.Logging; -namespace Microsoft.DotNet.Automation; +namespace Microsoft.DotNet.GitAutomation; internal sealed class GitWorkspace(string directory, ILogger logger) : IDisposable { diff --git a/src/Automation/IGitAccessTokenProvider.cs b/src/GitAutomation/IGitAccessTokenProvider.cs similarity index 93% rename from src/Automation/IGitAccessTokenProvider.cs rename to src/GitAutomation/IGitAccessTokenProvider.cs index 1812804b1..c8e02f0b2 100644 --- a/src/Automation/IGitAccessTokenProvider.cs +++ b/src/GitAutomation/IGitAccessTokenProvider.cs @@ -2,7 +2,7 @@ // 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; +namespace Microsoft.DotNet.GitAutomation; /// /// Provides access tokens for repository host operations. diff --git a/src/Automation/IGitContext.cs b/src/GitAutomation/IGitContext.cs similarity index 95% rename from src/Automation/IGitContext.cs rename to src/GitAutomation/IGitContext.cs index abd10c5cd..aebb162ca 100644 --- a/src/Automation/IGitContext.cs +++ b/src/GitAutomation/IGitContext.cs @@ -2,7 +2,7 @@ // 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; +namespace Microsoft.DotNet.GitAutomation; /// /// Provides git operations available to a pull request definition while it applies changes. diff --git a/src/Automation/IProcessRunner.cs b/src/GitAutomation/IProcessRunner.cs similarity index 97% rename from src/Automation/IProcessRunner.cs rename to src/GitAutomation/IProcessRunner.cs index b432ee86a..1df58d2e0 100644 --- a/src/Automation/IProcessRunner.cs +++ b/src/GitAutomation/IProcessRunner.cs @@ -2,7 +2,7 @@ // 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; +namespace Microsoft.DotNet.GitAutomation; /// /// Runs external processes and captures their output. diff --git a/src/Automation/IRepoHost.cs b/src/GitAutomation/IRepoHost.cs similarity index 91% rename from src/Automation/IRepoHost.cs rename to src/GitAutomation/IRepoHost.cs index 88599e12f..b9ea359a7 100644 --- a/src/Automation/IRepoHost.cs +++ b/src/GitAutomation/IRepoHost.cs @@ -2,7 +2,7 @@ // 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; +namespace Microsoft.DotNet.GitAutomation; internal interface IRepoHost { diff --git a/src/Automation/Microsoft.DotNet.Automation.csproj b/src/GitAutomation/Microsoft.DotNet.GitAutomation.csproj similarity index 94% rename from src/Automation/Microsoft.DotNet.Automation.csproj rename to src/GitAutomation/Microsoft.DotNet.GitAutomation.csproj index 280b6f9e1..55a2faa57 100644 --- a/src/Automation/Microsoft.DotNet.Automation.csproj +++ b/src/GitAutomation/Microsoft.DotNet.GitAutomation.csproj @@ -28,7 +28,7 @@ - + diff --git a/src/Automation/Models.cs b/src/GitAutomation/Models.cs similarity index 99% rename from src/Automation/Models.cs rename to src/GitAutomation/Models.cs index 106bc4b4d..4d33cc7de 100644 --- a/src/Automation/Models.cs +++ b/src/GitAutomation/Models.cs @@ -4,7 +4,7 @@ using System.Text.RegularExpressions; -namespace Microsoft.DotNet.Automation; +namespace Microsoft.DotNet.GitAutomation; /// /// Defines the desired pull request content and the changes that produce it. diff --git a/src/Automation/Planner.cs b/src/GitAutomation/Planner.cs similarity index 98% rename from src/Automation/Planner.cs rename to src/GitAutomation/Planner.cs index 8fbd549c5..6f09c65d9 100644 --- a/src/Automation/Planner.cs +++ b/src/GitAutomation/Planner.cs @@ -2,7 +2,7 @@ // 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; +namespace Microsoft.DotNet.GitAutomation; internal static class Planner { diff --git a/src/Automation/ProcessRunner.cs b/src/GitAutomation/ProcessRunner.cs similarity index 98% rename from src/Automation/ProcessRunner.cs rename to src/GitAutomation/ProcessRunner.cs index 10b2ccf05..2fbf3bf71 100644 --- a/src/Automation/ProcessRunner.cs +++ b/src/GitAutomation/ProcessRunner.cs @@ -5,7 +5,7 @@ using System.Diagnostics; using Microsoft.Extensions.Logging; -namespace Microsoft.DotNet.Automation; +namespace Microsoft.DotNet.GitAutomation; /// /// Runs external processes using . diff --git a/src/Automation/ProcessRunnerExtensions.cs b/src/GitAutomation/ProcessRunnerExtensions.cs similarity index 96% rename from src/Automation/ProcessRunnerExtensions.cs rename to src/GitAutomation/ProcessRunnerExtensions.cs index 993d8effe..190e92f0c 100644 --- a/src/Automation/ProcessRunnerExtensions.cs +++ b/src/GitAutomation/ProcessRunnerExtensions.cs @@ -2,7 +2,7 @@ // 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; +namespace Microsoft.DotNet.GitAutomation; /// /// Convenience methods for running external processes. diff --git a/src/Automation/PullRequestAutomationServiceCollectionExtensions.cs b/src/GitAutomation/PullRequestAutomationServiceCollectionExtensions.cs similarity index 98% rename from src/Automation/PullRequestAutomationServiceCollectionExtensions.cs rename to src/GitAutomation/PullRequestAutomationServiceCollectionExtensions.cs index cce0438d3..4d012cfce 100644 --- a/src/Automation/PullRequestAutomationServiceCollectionExtensions.cs +++ b/src/GitAutomation/PullRequestAutomationServiceCollectionExtensions.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -namespace Microsoft.DotNet.Automation; +namespace Microsoft.DotNet.GitAutomation; /// /// Registers services for declarative pull request automation. diff --git a/src/Automation/PullRequestManager.cs b/src/GitAutomation/PullRequestManager.cs similarity index 98% rename from src/Automation/PullRequestManager.cs rename to src/GitAutomation/PullRequestManager.cs index f75831d5a..61bfff97d 100644 --- a/src/Automation/PullRequestManager.cs +++ b/src/GitAutomation/PullRequestManager.cs @@ -2,12 +2,12 @@ // 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.DotNet.GitAutomation.GitHub; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Octokit; -namespace Microsoft.DotNet.Automation; +namespace Microsoft.DotNet.GitAutomation; /// /// Creates or updates a pull request to match a definition: clone the branch the @@ -249,7 +249,7 @@ public async Task CreateOrUpdateAsync( private static IGitHubClient CreateClient(string token) { - var productHeaderValue = new ProductHeaderValue("Microsoft.DotNet.Automation"); + var productHeaderValue = new ProductHeaderValue("Microsoft.DotNet.GitAutomation"); var credentials = new Credentials(token); return new GitHubClient(productHeaderValue) { Credentials = credentials }; } diff --git a/src/Automation/README.md b/src/GitAutomation/README.md similarity index 95% rename from src/Automation/README.md rename to src/GitAutomation/README.md index ee2343f8d..6f3479a3d 100644 --- a/src/Automation/README.md +++ b/src/GitAutomation/README.md @@ -1,6 +1,6 @@ -# Microsoft.DotNet.Automation +# Microsoft.DotNet.GitAutomation -`Microsoft.DotNet.Automation` is a library for declaratively managing +`Microsoft.DotNet.GitAutomation` is a library for declaratively managing pull requests and (eventually) issues. This pattern is most useful for automation that repeatedly opens or updates @@ -22,8 +22,8 @@ accomplish https://github.com/dotnet/docker-tools/issues/1658. ### Open a pull request ```csharp -using Microsoft.DotNet.Automation; -using Microsoft.DotNet.Automation.GitHub; +using Microsoft.DotNet.GitAutomation; +using Microsoft.DotNet.GitAutomation.GitHub; // Instantiate the pull request manager. var pullRequestManager = new PullRequestManager( diff --git a/src/Automation/StaticGitAccessTokenProvider.cs b/src/GitAutomation/StaticGitAccessTokenProvider.cs similarity index 95% rename from src/Automation/StaticGitAccessTokenProvider.cs rename to src/GitAutomation/StaticGitAccessTokenProvider.cs index 58381b280..3e9f0237b 100644 --- a/src/Automation/StaticGitAccessTokenProvider.cs +++ b/src/GitAutomation/StaticGitAccessTokenProvider.cs @@ -2,7 +2,7 @@ // 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; +namespace Microsoft.DotNet.GitAutomation; /// /// Provides a fixed access token, such as a GitHub or Azure DevOps personal access token. From eacbda26b5d4541eadd6592e88a27b5c5262b191 Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Fri, 10 Jul 2026 13:15:31 -0700 Subject: [PATCH 11/16] Remove superfluous tests --- .../ProcessRunnerExtensionsTests.cs | 34 ------------------- 1 file changed, 34 deletions(-) delete mode 100644 src/GitAutomation.Tests/ProcessRunnerExtensionsTests.cs diff --git a/src/GitAutomation.Tests/ProcessRunnerExtensionsTests.cs b/src/GitAutomation.Tests/ProcessRunnerExtensionsTests.cs deleted file mode 100644 index 6485536f8..000000000 --- a/src/GitAutomation.Tests/ProcessRunnerExtensionsTests.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -namespace Microsoft.DotNet.GitAutomation.Tests; - -[TestClass] -public sealed class ProcessRunnerExtensionsTests -{ - [TestMethod] - public async Task RunAsyncExtension_UsesCurrentWorkingDirectory() - { - StubProcessRunner processRunner = new(new ProcessResult(0, string.Empty, string.Empty)); - - await processRunner.RunAsync("git", ["--version"], CancellationToken.None); - - Assert.IsNull(processRunner.WorkingDirectory); - } - - private sealed class StubProcessRunner(ProcessResult result) : IProcessRunner - { - public string? WorkingDirectory { get; private set; } - - public Task RunAsync( - string? workingDirectory, - string fileName, - IEnumerable arguments, - CancellationToken cancellationToken) - { - WorkingDirectory = workingDirectory; - return Task.FromResult(result); - } - } -} From 3e79d8ca892eb18fbec4bef7c1376f65bd535938 Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Mon, 13 Jul 2026 13:13:06 -0700 Subject: [PATCH 12/16] Use standard logging registration Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 54d39da9-5918-4058-ad46-95935dcc127c --- src/GitAutomation/Microsoft.DotNet.GitAutomation.csproj | 1 + .../PullRequestAutomationServiceCollectionExtensions.cs | 5 +---- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/GitAutomation/Microsoft.DotNet.GitAutomation.csproj b/src/GitAutomation/Microsoft.DotNet.GitAutomation.csproj index 55a2faa57..a2f4979b2 100644 --- a/src/GitAutomation/Microsoft.DotNet.GitAutomation.csproj +++ b/src/GitAutomation/Microsoft.DotNet.GitAutomation.csproj @@ -23,6 +23,7 @@ + diff --git a/src/GitAutomation/PullRequestAutomationServiceCollectionExtensions.cs b/src/GitAutomation/PullRequestAutomationServiceCollectionExtensions.cs index 4d012cfce..e47c84b73 100644 --- a/src/GitAutomation/PullRequestAutomationServiceCollectionExtensions.cs +++ b/src/GitAutomation/PullRequestAutomationServiceCollectionExtensions.cs @@ -5,7 +5,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; namespace Microsoft.DotNet.GitAutomation; @@ -52,9 +51,7 @@ public static IServiceCollection AddPullRequestAutomation( ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(identity); - services.TryAddSingleton(NullLoggerFactory.Instance); - services.TryAddSingleton>(provider => - provider.GetRequiredService().CreateLogger()); + services.AddLogging(); services.TryAddSingleton(identity); services.TryAddSingleton(); services.TryAddSingleton(); From 3d65b30d0f937772dbe7e9d848f8575c790fe3e4 Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Mon, 13 Jul 2026 13:15:11 -0700 Subject: [PATCH 13/16] Remove redundant logging abstractions reference Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 54d39da9-5918-4058-ad46-95935dcc127c --- src/GitAutomation/Microsoft.DotNet.GitAutomation.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/src/GitAutomation/Microsoft.DotNet.GitAutomation.csproj b/src/GitAutomation/Microsoft.DotNet.GitAutomation.csproj index a2f4979b2..65960d68c 100644 --- a/src/GitAutomation/Microsoft.DotNet.GitAutomation.csproj +++ b/src/GitAutomation/Microsoft.DotNet.GitAutomation.csproj @@ -24,7 +24,6 @@ - From e97e1b3706f1241bbd6dadf5009783093f396dd5 Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Mon, 13 Jul 2026 13:25:06 -0700 Subject: [PATCH 14/16] Version GitAutomation independently Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 54d39da9-5918-4058-ad46-95935dcc127c --- src/GitAutomation/Microsoft.DotNet.GitAutomation.csproj | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/GitAutomation/Microsoft.DotNet.GitAutomation.csproj b/src/GitAutomation/Microsoft.DotNet.GitAutomation.csproj index 65960d68c..2b7c39d7c 100644 --- a/src/GitAutomation/Microsoft.DotNet.GitAutomation.csproj +++ b/src/GitAutomation/Microsoft.DotNet.GitAutomation.csproj @@ -16,6 +16,9 @@ true + 0 + 1 + 0 Declarative git automation library. false From 10406a9621dc0a4cf0fe26e389538c4378d883c8 Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Mon, 13 Jul 2026 13:25:06 -0700 Subject: [PATCH 15/16] Trigger package builds for GitAutomation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 54d39da9-5918-4058-ad46-95935dcc127c --- eng/pipelines/docker-tools-packages-official.yml | 1 + eng/pipelines/docker-tools-packages-pr.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/eng/pipelines/docker-tools-packages-official.yml b/eng/pipelines/docker-tools-packages-official.yml index 09e1670d2..6164500a0 100644 --- a/eng/pipelines/docker-tools-packages-official.yml +++ b/eng/pipelines/docker-tools-packages-official.yml @@ -17,6 +17,7 @@ trigger: include: - eng/common/* - eng/pipelines/* + - src/GitAutomation/* - src/ImageBuilder/* - src/ImageBuilder.Models/* - Directory.Build.props diff --git a/eng/pipelines/docker-tools-packages-pr.yml b/eng/pipelines/docker-tools-packages-pr.yml index c32bc0e5c..36240f771 100644 --- a/eng/pipelines/docker-tools-packages-pr.yml +++ b/eng/pipelines/docker-tools-packages-pr.yml @@ -16,6 +16,7 @@ pr: include: - eng/common/* - eng/pipelines/* + - src/GitAutomation/* - src/ImageBuilder/* - src/ImageBuilder.Models/* - Directory.Build.props From f3f8569ea2a4e00f4f6dee313de14a71010662ab Mon Sep 17 00:00:00 2001 From: Logan Bussell Date: Mon, 13 Jul 2026 13:25:28 -0700 Subject: [PATCH 16/16] Include GitAutomation README in package Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 54d39da9-5918-4058-ad46-95935dcc127c --- src/GitAutomation/Microsoft.DotNet.GitAutomation.csproj | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/GitAutomation/Microsoft.DotNet.GitAutomation.csproj b/src/GitAutomation/Microsoft.DotNet.GitAutomation.csproj index 2b7c39d7c..5df224fb9 100644 --- a/src/GitAutomation/Microsoft.DotNet.GitAutomation.csproj +++ b/src/GitAutomation/Microsoft.DotNet.GitAutomation.csproj @@ -20,6 +20,7 @@ 1 0 Declarative git automation library. + README.md false @@ -34,4 +35,8 @@ + + + +