Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Microsoft.DotNet.DockerTools.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
<Project Path="eng/src/file-pusher/file-pusher.csproj" />
<Project Path="eng/src/yaml-updater/yaml-updater.csproj" />
<Project Path="src/ImageBuilder/Microsoft.DotNet.ImageBuilder.csproj" />
<Project Path="src/Automation/Microsoft.DotNet.Automation.csproj" />
<Project Path="src/Automation.Tests/Microsoft.DotNet.Automation.Tests.csproj" />
<Project Path="src/ImageBuilder.Models/Microsoft.DotNet.ImageBuilder.Models.csproj" />
<Project Path="src/ImageBuilder.Tests/Microsoft.DotNet.ImageBuilder.Tests.csproj" />
</Solution>
89 changes: 89 additions & 0 deletions src/Automation.Tests/AutomationHarness.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// The front door of the test harness. Wires real local bare-git remotes (via
/// <see cref="GitTestRepo"/>/<see cref="LocalRemoteRepo"/>) and an in-memory
/// <see cref="FakePullRequestApi"/> into a <see cref="RepoHostEngine"/>.
///
/// A test:
/// <list type="number">
/// <item>creates a harness (the initial state of the world),</item>
/// <item>optionally seeds extra branches/commits (<see cref="Target"/>/<see cref="Head"/>)
/// and pull requests (<see cref="PullRequests"/>),</item>
/// <item>takes an action (<see cref="EnsurePullRequestAsync"/> /
/// <see cref="EnsureBranchContentAsync"/>),</item>
/// <item>asserts on the result and on what was pushed / which pull request
/// operations ran.</item>
/// </list>
/// </summary>
internal sealed class AutomationHarness : IDisposable
{
/// <summary>The identity the automation commits and pushes as.</summary>
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;
}

/// <summary>The repository pull requests merge into and branches are pushed to.</summary>
public GitTestRepo Target => _target;

/// <summary>
/// The repository pull request head branches are pushed to. The same as
/// <see cref="Target"/> unless the harness was created with a fork.
/// </summary>
public GitTestRepo Head => _fork ?? _target;

/// <summary>The in-memory pull request API used to seed and inspect pull requests.</summary>
public FakePullRequestApi PullRequests { get; }

/// <summary>Creates a harness whose target repo has a single commit on <paramref name="targetBranch"/>.</summary>
/// <param name="options">Automation settings; defaults to <see cref="DefaultAuthor"/> with an empty token.</param>
/// <param name="targetBranch">The branch the target repo is initialized with.</param>
/// <param name="useFork">When true, head branches are pushed to a separate fork remote.</param>
public static async Task<AutomationHarness> 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<PullRequestResult> EnsurePullRequestAsync(
PullRequestSpec spec, CancellationToken cancellationToken = default) =>
_engine.EnsurePullRequestAsync(spec, cancellationToken);

public Task<BranchResult> EnsureBranchContentAsync(
BranchSpec spec, CancellationToken cancellationToken = default) =>
_engine.EnsureBranchContentAsync(spec, cancellationToken);

public void Dispose()
{
_target.Dispose();
_fork?.Dispose();
}
}
180 changes: 180 additions & 0 deletions src/Automation.Tests/AutomationHarnessTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
[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<IGitContext, CancellationToken, Task> 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);
};
}
89 changes: 89 additions & 0 deletions src/Automation.Tests/FakePullRequestApi.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>A pull request creation recorded by <see cref="FakePullRequestApi"/>.</summary>
internal sealed record RecordedCreate(long Id, string Title, string Body, string HeadBranch, string TargetBranch);

/// <summary>A pull request metadata update recorded by <see cref="FakePullRequestApi"/>.</summary>
internal sealed record RecordedUpdate(long Id, string Title, string Body);

/// <summary>A comment added to a pull request, recorded by <see cref="FakePullRequestApi"/>.</summary>
internal sealed record RecordedComment(long PullRequestId, string Body);

/// <summary>
/// An in-memory <see cref="IPullRequestApi"/> 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).
/// </summary>
internal sealed class FakePullRequestApi : IPullRequestApi
{
private readonly List<StoredPullRequest> _pullRequests = [];
private readonly List<RecordedCreate> _creates = [];
private readonly List<RecordedUpdate> _updates = [];
private readonly List<RecordedComment> _comments = [];
private long _nextId = 1;

public IReadOnlyList<RecordedCreate> Creates => _creates;

public IReadOnlyList<RecordedUpdate> Updates => _updates;

public IReadOnlyList<RecordedComment> Comments => _comments;

/// <summary>Seeds a pre-existing open pull request and returns its id.</summary>
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<PullRequestInfo?> FindOpenAsync(
string headBranch, string targetBranch, CancellationToken cancellationToken)
{
StoredPullRequest? match = _pullRequests.FirstOrDefault(
pullRequest => pullRequest.HeadBranch == headBranch && pullRequest.TargetBranch == targetBranch);

return Task.FromResult<PullRequestInfo?>(
match is null ? null : new PullRequestInfo(match.Id, match.Url, match.Title, match.Body));
}

public Task<PullRequestInfo> 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<IReadOnlyList<string>> GetCommentsAsync(long id, CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<string>>(
[.. _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);
}
Loading
Loading