Merge branch 'claudedo/387-unit-branch-teil-3-firmen-modus-fetch-pu'

This commit is contained in:
mika kuns
2026-10-01 12:30:13 +02:00
40 changed files with 2857 additions and 47 deletions
@@ -3,6 +3,7 @@ using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.AzureDevOps;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.State;
@@ -15,13 +16,15 @@ namespace ClaudeDo.Worker.Lifecycle;
public sealed record MergeResult(
string Status,
IReadOnlyList<string> ConflictFiles,
string? ErrorMessage);
string? ErrorMessage,
string? PullRequestUrl = null);
public sealed record MergeTargets(
string DefaultBranch,
IReadOnlyList<string> LocalBranches,
string DefaultCommitMessage,
bool TargetDirty = false);
bool TargetDirty = false,
bool IsCompanyRepo = false);
// VerifyExitCode/VerifyDurationMs/VerifyOutputTail are only populated when the caller asked for
// a verify run (runVerify=true) AND the list has a verify command configured AND the preview came
@@ -76,6 +79,11 @@ public sealed class TaskMergeService
// autostash: true) without matching on prose.
public const string StatusDirtyTarget = "dirty_target";
// Company repo: approve pushed the unit branch and opened (or reused) its draft PR; the task stays
// WaitingForReview, nothing landed locally.
public const string StatusPrCreated = "pr_created";
public const string StatusPrUpdated = "pr_updated";
public const string StatusReverted = "reverted";
public const string StatusConflictAborted = "conflict_aborted";
@@ -119,6 +127,7 @@ public sealed class TaskMergeService
private readonly ILogger<TaskMergeService> _logger;
private readonly UnitBranchService? _unitBranch;
private readonly RunningOperationRegistry _operations;
private readonly PullRequestPublisher? _pullRequests;
public TaskMergeService(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
@@ -128,7 +137,8 @@ public sealed class TaskMergeService
IVerifyCommandRunner verify,
ILogger<TaskMergeService> logger,
UnitBranchService? unitBranch = null,
RunningOperationRegistry? operations = null)
RunningOperationRegistry? operations = null,
PullRequestPublisher? pullRequests = null)
{
_dbFactory = dbFactory;
_git = git;
@@ -138,6 +148,7 @@ public sealed class TaskMergeService
_logger = logger;
_unitBranch = unitBranch;
_operations = operations ?? new RunningOperationRegistry();
_pullRequests = pullRequests;
}
private IDisposable TrackTask(string kind, TaskEntity task) =>
@@ -893,7 +904,8 @@ public sealed class TaskMergeService
var current = await ResolveTargetAsync(list, null, ct);
var branches = await _git.ListLocalBranchesAsync(list.WorkingDir, ct);
var dirty = await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct);
return new MergeTargets(current, branches, defaultMessage, dirty);
var company = _pullRequests is not null && await _pullRequests.IsCompanyRepoAsync(list.WorkingDir, ct);
return new MergeTargets(current, branches, defaultMessage, dirty, company);
}
/// <summary>Leeres Ziel → <c>list_config.base_branch</c>, sonst der ausgecheckte Branch des Main-Checkouts.</summary>
@@ -1054,6 +1066,12 @@ public sealed class TaskMergeService
}
}
// Kept (parked or reverted) goes the PR way too: a local merge has no place in a company repo.
if (wt is { State: WorktreeState.Active or WorktreeState.Kept } && _pullRequests is not null
&& !string.IsNullOrWhiteSpace(list.WorkingDir)
&& await _pullRequests.IsCompanyRepoAsync(list.WorkingDir, ct))
return await ApproveCompanyAsync(task, list, wt, verifyCommand, ct, progress);
if (wt is not null && wt.State == WorktreeState.Kept)
{
// Kept means either RevertMergeAsync undid a previous merge, or the user manually
@@ -1137,6 +1155,42 @@ public sealed class TaskMergeService
return await MergeAsync(taskId, target, removeWorktree: true, commitMessage: "", leaveConflictsInTree, ct, progress, skipVerify, autostash);
}
/// <summary>
/// Company repo: verify in the unit root's worktree (nothing lands on a local target, so there is no
/// merge to defer the verify to — <c>skipVerify</c> does not apply), then push + draft PR. Red verify
/// pushes nothing. Status, worktree state and ticket stay as they are.
/// </summary>
private async Task<MergeResult> ApproveCompanyAsync(
TaskEntity task, ListEntity list, WorktreeEntity wt, string? verifyCommand, CancellationToken ct,
IProgress<ProgressNotificationValue>? progress)
{
if (await _pullRequests!.CheckReadyAsync(list.WorkingDir!, ct) is { } notReady)
return Blocked(notReady);
var ranVerify = !string.IsNullOrWhiteSpace(verifyCommand);
if (ranVerify)
{
await _broadcaster.OperationProgress(task.Id, PhaseVerifying, 0, 0);
var failed = await RunVerifyGateAsync(
verifyCommand, wt.Path, () => TrackTask(OperationKinds.VerifyMerge, task), ct, progress,
elapsed => _ = _broadcaster.OperationProgress(task.Id, PhaseVerifying, (int)elapsed.TotalSeconds, 0));
if (failed is not null) return failed;
}
var result = await ProgressReporter.RunAsync(
_pullRequests.PublishAsync(task, list, wt, ranVerify ? verifyCommand : null, ct),
ProgressReportInterval, progress, "push + draft PR running");
// A branch with an open PR is under review again, whatever parked it before.
if (wt.State == WorktreeState.Kept && result.PullRequestUrl is not null)
{
using (var ctx = _dbFactory.CreateDbContext())
await new WorktreeRepository(ctx).SetStateAsync(task.Id, WorktreeState.Active, ct);
await _broadcaster.WorktreeUpdated(task.Id);
}
return result;
}
/// <summary>
/// Redoes a merge that <see cref="RevertMergeAsync"/> undid (or that predates it, if the branch
/// was already merged before its worktree reached <see cref="WorktreeState.Kept"/>), by