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;
}
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;
}
}