feat(worker): approve merges worktree before marking task done
This commit is contained in:
@@ -3,6 +3,7 @@ using ClaudeDo.Data.Git;
|
|||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Worker.Hub;
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using ClaudeDo.Worker.State;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
@@ -17,6 +18,11 @@ public sealed record MergeTargets(
|
|||||||
string DefaultBranch,
|
string DefaultBranch,
|
||||||
IReadOnlyList<string> LocalBranches);
|
IReadOnlyList<string> LocalBranches);
|
||||||
|
|
||||||
|
public sealed record MergePreviewResult(
|
||||||
|
string Status,
|
||||||
|
IReadOnlyList<string> ConflictFiles,
|
||||||
|
int ChangedFileCount);
|
||||||
|
|
||||||
public sealed class TaskMergeService
|
public sealed class TaskMergeService
|
||||||
{
|
{
|
||||||
public const string StatusMerged = "merged";
|
public const string StatusMerged = "merged";
|
||||||
@@ -24,20 +30,27 @@ public sealed class TaskMergeService
|
|||||||
public const string StatusBlocked = "blocked";
|
public const string StatusBlocked = "blocked";
|
||||||
public const string StatusAborted = "aborted";
|
public const string StatusAborted = "aborted";
|
||||||
|
|
||||||
|
public const string PreviewClean = "clean";
|
||||||
|
public const string PreviewConflict = "conflict";
|
||||||
|
public const string PreviewUnavailable = "unavailable";
|
||||||
|
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly GitService _git;
|
private readonly GitService _git;
|
||||||
private readonly HubBroadcaster _broadcaster;
|
private readonly HubBroadcaster _broadcaster;
|
||||||
|
private readonly ITaskStateService _state;
|
||||||
private readonly ILogger<TaskMergeService> _logger;
|
private readonly ILogger<TaskMergeService> _logger;
|
||||||
|
|
||||||
public TaskMergeService(
|
public TaskMergeService(
|
||||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
GitService git,
|
GitService git,
|
||||||
HubBroadcaster broadcaster,
|
HubBroadcaster broadcaster,
|
||||||
|
ITaskStateService state,
|
||||||
ILogger<TaskMergeService> logger)
|
ILogger<TaskMergeService> logger)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
_git = git;
|
_git = git;
|
||||||
_broadcaster = broadcaster;
|
_broadcaster = broadcaster;
|
||||||
|
_state = state;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,6 +229,56 @@ public sealed class TaskMergeService
|
|||||||
return new MergeTargets(current, branches);
|
return new MergeTargets(current, branches);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<MergePreviewResult> PreviewAsync(string taskId, string targetBranch, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (_, list, wt) = await LoadMergeContextAsync(taskId, ct);
|
||||||
|
|
||||||
|
if (wt is null || wt.State != WorktreeState.Active)
|
||||||
|
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
|
||||||
|
if (string.IsNullOrWhiteSpace(list.WorkingDir) || !await _git.IsGitRepoAsync(list.WorkingDir, ct))
|
||||||
|
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
|
||||||
|
|
||||||
|
var target = string.IsNullOrWhiteSpace(targetBranch)
|
||||||
|
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
|
||||||
|
: targetBranch;
|
||||||
|
|
||||||
|
var preview = await _git.PreviewMergeAsync(list.WorkingDir, target, wt.BranchName, ct);
|
||||||
|
if (!preview.Supported)
|
||||||
|
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
|
||||||
|
if (!preview.Clean)
|
||||||
|
return new MergePreviewResult(PreviewConflict, preview.ConflictFiles, 0);
|
||||||
|
|
||||||
|
var count = await _git.CountChangedFilesAsync(list.WorkingDir, target, wt.BranchName, ct);
|
||||||
|
return new MergePreviewResult(PreviewClean, Array.Empty<string>(), count);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MergeResult> ApproveAndMergeAsync(string taskId, string targetBranch, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (task, list, wt) = await LoadMergeContextAsync(taskId, ct);
|
||||||
|
|
||||||
|
if (task.Status != TaskStatus.WaitingForReview)
|
||||||
|
return Blocked("task is not waiting for review");
|
||||||
|
|
||||||
|
if (wt is null || wt.State != WorktreeState.Active)
|
||||||
|
{
|
||||||
|
var done = await _state.ApproveReviewAsync(taskId, ct);
|
||||||
|
return done.Ok
|
||||||
|
? new MergeResult(StatusMerged, Array.Empty<string>(), null)
|
||||||
|
: Blocked(done.Reason ?? "approve failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = string.IsNullOrWhiteSpace(targetBranch)
|
||||||
|
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
|
||||||
|
: targetBranch;
|
||||||
|
|
||||||
|
var merge = await MergeAsync(taskId, target, removeWorktree: false, $"Merge {wt.BranchName}", ct);
|
||||||
|
if (merge.Status != StatusMerged)
|
||||||
|
return merge;
|
||||||
|
|
||||||
|
var approve = await _state.ApproveReviewAsync(taskId, ct);
|
||||||
|
return approve.Ok ? merge : Blocked(approve.Reason ?? "approve failed");
|
||||||
|
}
|
||||||
|
|
||||||
private static MergeResult Blocked(string reason) =>
|
private static MergeResult Blocked(string reason) =>
|
||||||
new(StatusBlocked, Array.Empty<string>(), reason);
|
new(StatusBlocked, Array.Empty<string>(), reason);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,10 +34,12 @@ public class TaskMergeServiceTests : IDisposable
|
|||||||
{
|
{
|
||||||
var fakeHub = new MergeRecordingHubContext();
|
var fakeHub = new MergeRecordingHubContext();
|
||||||
var broadcaster = new HubBroadcaster(fakeHub);
|
var broadcaster = new HubBroadcaster(fakeHub);
|
||||||
|
var state = TaskStateServiceBuilder.Build(db.CreateFactory()).State;
|
||||||
var svc = new TaskMergeService(
|
var svc = new TaskMergeService(
|
||||||
db.CreateFactory(),
|
db.CreateFactory(),
|
||||||
new GitService(),
|
new GitService(),
|
||||||
broadcaster,
|
broadcaster,
|
||||||
|
state,
|
||||||
NullLogger<TaskMergeService>.Instance);
|
NullLogger<TaskMergeService>.Instance);
|
||||||
return (svc, fakeHub.Proxy);
|
return (svc, fakeHub.Proxy);
|
||||||
}
|
}
|
||||||
@@ -442,6 +444,146 @@ public class TaskMergeServiceTests : IDisposable
|
|||||||
Assert.Equal(WorktreeState.Active, wt.State);
|
Assert.Equal(WorktreeState.Active, wt.State);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewAsync_CleanWorktree_ReturnsClean()
|
||||||
|
{
|
||||||
|
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"), "x\n");
|
||||||
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
||||||
|
|
||||||
|
var (svc, _) = BuildService(db);
|
||||||
|
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||||
|
|
||||||
|
var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.PreviewClean, preview.Status);
|
||||||
|
Assert.True(preview.ChangedFileCount >= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewAsync_Conflict_ReturnsConflictFiles()
|
||||||
|
{
|
||||||
|
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, "README.md"), "# from worktree\n");
|
||||||
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
||||||
|
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from main\n");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");
|
||||||
|
|
||||||
|
var (svc, _) = BuildService(db);
|
||||||
|
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||||
|
|
||||||
|
var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.PreviewConflict, preview.Status);
|
||||||
|
Assert.Contains("README.md", preview.ConflictFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewAsync_NoActiveWorktree_ReturnsUnavailable()
|
||||||
|
{
|
||||||
|
var db = NewDb();
|
||||||
|
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
|
||||||
|
var (svc, _) = BuildService(db);
|
||||||
|
|
||||||
|
var preview = await svc.PreviewAsync(task.Id, "main", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.PreviewUnavailable, preview.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveAndMergeAsync_CleanWorktree_MergesAndMarksDone()
|
||||||
|
{
|
||||||
|
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 (svc, _) = BuildService(db);
|
||||||
|
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||||
|
|
||||||
|
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
|
||||||
|
Assert.Equal(TaskStatus.Done, updated!.Status);
|
||||||
|
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
|
||||||
|
Assert.Equal(WorktreeState.Merged, wt!.State);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveAndMergeAsync_Conflict_LeavesTaskWaitingForReview()
|
||||||
|
{
|
||||||
|
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, "README.md"), "# from worktree\n");
|
||||||
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
||||||
|
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from main\n");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");
|
||||||
|
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
||||||
|
|
||||||
|
var (svc, _) = BuildService(db);
|
||||||
|
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||||
|
|
||||||
|
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.StatusConflict, result.Status);
|
||||||
|
Assert.Contains("README.md", result.ConflictFiles);
|
||||||
|
|
||||||
|
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.Active, wt!.State);
|
||||||
|
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
|
||||||
|
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveAndMergeAsync_NoWorktree_MarksDone()
|
||||||
|
{
|
||||||
|
var db = NewDb();
|
||||||
|
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
|
||||||
|
var (svc, _) = BuildService(db);
|
||||||
|
|
||||||
|
var result = await svc.ApproveAndMergeAsync(task.Id, "main", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
|
||||||
|
Assert.Equal(TaskStatus.Done, updated!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task MergeAsync_LeaveConflicts_DoesNotAbortAndReturnsConflictFiles()
|
public async Task MergeAsync_LeaveConflicts_DoesNotAbortAndReturnsConflictFiles()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user