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