feat(worker): add PlanningAggregator.BuildIntegrationBranchAsync

This commit is contained in:
mika kuns
2026-04-24 16:18:45 +02:00
parent a1727b647c
commit 2cab33d708
2 changed files with 181 additions and 0 deletions

View File

@@ -70,4 +70,90 @@ public sealed class PlanningAggregator
} }
return result; return result;
} }
public async Task<CombinedDiffResult> BuildIntegrationBranchAsync(
string planningTaskId, string targetBranch, CancellationToken ct)
{
var (planning, repoDir, childSubtasks) = await LoadPlanningContextAsync(planningTaskId, ct);
var integrationBranch = BuildIntegrationBranchName(planning);
// Reset: delete if exists, then recreate off the target branch.
try { await _git.BranchDeleteAsync(repoDir, integrationBranch, force: true, ct); }
catch { /* didn't exist */ }
await _git.CheckoutBranchAsync(repoDir, targetBranch, ct);
await GitRawAsync(repoDir, ct, "checkout", "-b", integrationBranch);
foreach (var child in childSubtasks)
{
if (child.Worktree is null) continue;
var (code, _) = await _git.MergeNoFfAsync(
repoDir, child.Worktree.BranchName,
$"Integrate subtask: {child.Title}", ct);
if (code != 0)
{
List<string> files;
try { files = await _git.ListConflictedFilesAsync(repoDir, ct); }
catch { files = new(); }
try { await _git.MergeAbortAsync(repoDir, ct); } catch { }
try { await _git.CheckoutBranchAsync(repoDir, targetBranch, ct); } catch { }
try { await _git.BranchDeleteAsync(repoDir, integrationBranch, force: true, ct); } catch { }
return new CombinedDiffResult.Failed(
new CombinedDiffFailure(child.Id, files));
}
}
var unifiedDiff = await GitRawAsync(repoDir, ct, "diff", $"{targetBranch}..{integrationBranch}");
return new CombinedDiffResult.Ok(new CombinedDiffSuccess(integrationBranch, unifiedDiff));
}
private async Task<(TaskEntity planning, string repoDir, IReadOnlyList<TaskEntity> children)>
LoadPlanningContextAsync(string planningTaskId, CancellationToken ct)
{
using var ctx = _dbFactory.CreateDbContext();
var planning = await ctx.Tasks
.Include(t => t.List)
.Include(t => t.Children).ThenInclude(c => c.Worktree)
.SingleOrDefaultAsync(t => t.Id == planningTaskId, ct)
?? throw new KeyNotFoundException($"Planning task '{planningTaskId}' not found.");
var repoDir = planning.List.WorkingDir
?? throw new InvalidOperationException("List has no working directory.");
var children = planning.Children.OrderBy(c => c.SortOrder).ToList();
return (planning, repoDir, children);
}
internal static string BuildIntegrationBranchName(TaskEntity planning)
{
var slug = new string(planning.Title
.ToLowerInvariant()
.Select(c => char.IsLetterOrDigit(c) ? c : '-')
.ToArray())
.Trim('-');
if (string.IsNullOrEmpty(slug)) slug = planning.Id[..8];
if (slug.Length > 40) slug = slug[..40].TrimEnd('-');
return $"planning/{slug}-integration";
}
private static async Task<string> GitRawAsync(string cwd, CancellationToken ct, params string[] args)
{
var psi = new System.Diagnostics.ProcessStartInfo("git")
{
WorkingDirectory = cwd,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
foreach (var a in args) psi.ArgumentList.Add(a);
using var p = System.Diagnostics.Process.Start(psi)!;
var stdoutTask = p.StandardOutput.ReadToEndAsync();
var stderrTask = p.StandardError.ReadToEndAsync();
await p.WaitForExitAsync(ct);
var stdout = await stdoutTask;
var stderr = await stderrTask;
if (p.ExitCode != 0) throw new InvalidOperationException($"git {string.Join(' ', args)} failed: {stderr}");
return stdout;
}
} }

View File

@@ -116,4 +116,99 @@ public class PlanningAggregatorTests : IDisposable
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
}); });
} }
[Fact]
public async Task BuildIntegrationBranchAsync_NonConflictingChildren_ReturnsOkWithCombinedDiff()
{
var db = NewDb();
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
var (parentId, _, _) = await SeedPlanningWithTwoChildrenAsync(db, repo);
var git = new GitService();
var svc = new PlanningAggregator(db.CreateFactory(), git, NullLogger<PlanningAggregator>.Instance);
var result = await svc.BuildIntegrationBranchAsync(parentId, targetBranch: "main", CancellationToken.None);
var ok = Assert.IsType<CombinedDiffResult.Ok>(result);
Assert.EndsWith("-integration", ok.Value.IntegrationBranch);
Assert.Contains("diff --git", ok.Value.UnifiedDiff);
var branches = await git.ListLocalBranchesAsync(repo.RepoDir, CancellationToken.None);
Assert.Contains(branches, b => b == ok.Value.IntegrationBranch);
}
[Fact]
public async Task BuildIntegrationBranchAsync_ConflictingChildren_ReturnsFailedAndResetsBranch()
{
var db = NewDb();
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
var (parentId, subA, subB) = await SeedPlanningWithConflictingChildrenAsync(db, repo);
var git = new GitService();
var svc = new PlanningAggregator(db.CreateFactory(), git, NullLogger<PlanningAggregator>.Instance);
var result = await svc.BuildIntegrationBranchAsync(parentId, targetBranch: "main", CancellationToken.None);
var failed = Assert.IsType<CombinedDiffResult.Failed>(result);
Assert.Equal(subB, failed.Value.FirstConflictSubtaskId);
Assert.NotEmpty(failed.Value.ConflictedFiles);
Assert.False(await git.IsMidMergeAsync(repo.RepoDir, CancellationToken.None));
}
private async Task<(string parentId, string subA, string subB)> SeedPlanningWithConflictingChildrenAsync(
DbFixture db, GitRepoFixture repo)
{
using var ctx = db.CreateContext();
var listId = Guid.NewGuid().ToString();
ctx.Lists.Add(new ListEntity
{
Id = listId, Name = "test", CreatedAt = DateTime.UtcNow, WorkingDir = repo.RepoDir,
});
var parentId = Guid.NewGuid().ToString();
ctx.Tasks.Add(new TaskEntity
{
Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow,
Status = TaskStatus.Planning, SortOrder = 0,
});
var subA = Guid.NewGuid().ToString();
var subB = Guid.NewGuid().ToString();
ctx.Tasks.Add(new TaskEntity
{
Id = subA, ListId = listId, Title = "A", CreatedAt = DateTime.UtcNow,
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1,
});
ctx.Tasks.Add(new TaskEntity
{
Id = subB, ListId = listId, Title = "B", CreatedAt = DateTime.UtcNow,
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 2,
});
await ctx.SaveChangesAsync();
SeedWorktreeWithFile(ctx, repo, subA, "README.md", "A wins\n");
SeedWorktreeWithFile(ctx, repo, subB, "README.md", "B wins\n");
await ctx.SaveChangesAsync();
return (parentId, subA, subB);
}
private void SeedWorktreeWithFile(ClaudeDoDbContext ctx, GitRepoFixture repo, string taskId, string filename, string content)
{
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_wtCleanups.Add((repo.RepoDir, wtPath));
var branch = $"claudedo/{taskId[..8]}";
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit);
File.WriteAllText(Path.Combine(wtPath, filename), content);
GitRepoFixture.RunGit(wtPath, "add", filename);
GitRepoFixture.RunGit(wtPath, "commit", "-m", $"edit {filename}");
var head = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim();
ctx.Worktrees.Add(new WorktreeEntity
{
TaskId = taskId, Path = wtPath, BranchName = branch,
BaseCommit = repo.BaseCommit, HeadCommit = head,
DiffStat = null, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
});
}
} }