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