Merge branch 'claudedo/0b2fbb48d44c41558c21d3464c0bd5c2'

This commit is contained in:
mika kuns
2026-08-05 12:11:55 +02:00
30 changed files with 1473 additions and 130 deletions
@@ -30,7 +30,8 @@ public class TaskMergeServiceTests : IDisposable
foreach (var r in _repos) try { r.Dispose(); } catch { }
}
private static (TaskMergeService svc, MergeRecordingClientProxy proxy) BuildService(DbFixture db)
private static (TaskMergeService svc, MergeRecordingClientProxy proxy) BuildService(
DbFixture db, IVerifyCommandRunner? verify = null)
{
var fakeHub = new MergeRecordingHubContext();
var broadcaster = new HubBroadcaster(fakeHub);
@@ -40,10 +41,17 @@ public class TaskMergeServiceTests : IDisposable
new GitService(),
broadcaster,
state,
verify ?? new VerifyCommandRunner(),
NullLogger<TaskMergeService>.Instance);
return (svc, fakeHub.Proxy);
}
private static async Task SeedVerifyCommand(DbFixture db, string listId, string command)
{
using var ctx = db.CreateContext();
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = command });
}
private static WorktreeManager BuildWorktreeManager(DbFixture db)
{
return new WorktreeManager(
@@ -706,6 +714,125 @@ public class TaskMergeServiceTests : IDisposable
Assert.Equal(TaskStatus.Done, updated!.Status);
}
[Fact]
public async Task ApproveAndMergeAsync_NoVerifyCommandConfigured_NeverInvokesRunnerAndMarksDone()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
var wtMgr = BuildWorktreeManager(db);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "should never run") };
var (svc, _) = BuildService(db, fakeVerify);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
Assert.Null(fakeVerify.CapturedCommand);
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Done, updated!.Status);
}
[Fact]
public async Task ApproveAndMergeAsync_VerifyCommandSucceeds_RunsInListWorkingDirAndMarksDone()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
await SeedVerifyCommand(db, list.Id, "dotnet test");
var wtMgr = BuildWorktreeManager(db);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "all green") };
var (svc, _) = BuildService(db, fakeVerify);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
Assert.Equal("dotnet test", fakeVerify.CapturedCommand);
Assert.Equal(repo.RepoDir, fakeVerify.CapturedWorkingDir);
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Done, updated!.Status);
}
[Fact]
public async Task ApproveAndMergeAsync_VerifyCommandFails_KeepsMergeButNotDone()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
await SeedVerifyCommand(db, list.Id, "dotnet test");
var wtMgr = BuildWorktreeManager(db);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "build broke: CS0103") };
var (svc, _) = BuildService(db, fakeVerify);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusVerifyFailed, result.Status);
Assert.Contains("build broke: CS0103", result.ErrorMessage);
// The git merge itself is left in place — main already has the merged content.
Assert.True(File.Exists(Path.Combine(repo.RepoDir, "added.txt")));
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.WaitingForReview, updated!.Status);
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.Equal(WorktreeState.Merged, wt!.State);
}
[Fact]
public async Task ApproveAndMergeAsync_VerifyCommandTimesOut_ReturnsVerifyFailedWithTimeoutMessage()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
await SeedVerifyCommand(db, list.Id, "dotnet test");
var wtMgr = BuildWorktreeManager(db);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(-1, true, "") };
var (svc, _) = BuildService(db, fakeVerify);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusVerifyFailed, result.Status);
Assert.Contains("timed out", result.ErrorMessage ?? "", StringComparison.OrdinalIgnoreCase);
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.WaitingForReview, updated!.Status);
}
[Fact]
public async Task MergeAsync_LeaveConflicts_DoesNotAbortAndReturnsConflictFiles()
{
@@ -986,6 +1113,20 @@ public class TaskMergeServiceTests : IDisposable
#region Test doubles
internal sealed class FakeVerifyCommandRunner : IVerifyCommandRunner
{
public VerifyCommandResult Result { get; set; } = new(0, false, "");
public string? CapturedWorkingDir { get; private set; }
public string? CapturedCommand { get; private set; }
public Task<VerifyCommandResult> RunAsync(string workingDir, string command, TimeSpan timeout, CancellationToken ct)
{
CapturedWorkingDir = workingDir;
CapturedCommand = command;
return Task.FromResult(Result);
}
}
internal sealed record MergeHubCall(string Method, object?[] Args);
internal sealed class MergeRecordingClientProxy : IClientProxy