feat(worker): approve in a company repo verifies in the worktree, pushes and opens a draft PR

This commit is contained in:
mika kuns
2026-10-01 12:05:33 +02:00
parent 7f7f237656
commit bc959f25b3
7 changed files with 454 additions and 9 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";
@@ -118,6 +126,7 @@ public sealed class TaskMergeService
private readonly IVerifyCommandRunner _verify;
private readonly ILogger<TaskMergeService> _logger;
private readonly UnitBranchService? _unitBranch;
private readonly PullRequestPublisher? _pullRequests;
public TaskMergeService(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
@@ -126,7 +135,8 @@ public sealed class TaskMergeService
ITaskStateService state,
IVerifyCommandRunner verify,
ILogger<TaskMergeService> logger,
UnitBranchService? unitBranch = null)
UnitBranchService? unitBranch = null,
PullRequestPublisher? pullRequests = null)
{
_dbFactory = dbFactory;
_git = git;
@@ -135,6 +145,7 @@ public sealed class TaskMergeService
_verify = verify;
_logger = logger;
_unitBranch = unitBranch;
_pullRequests = pullRequests;
}
private async Task<(TaskEntity Task, ListEntity List, WorktreeEntity? Worktree, string? VerifyCommand)> LoadMergeContextAsync(
@@ -881,7 +892,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>
@@ -1042,6 +1054,11 @@ public sealed class TaskMergeService
}
}
if (wt is { State: WorktreeState.Active } && _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
@@ -1125,6 +1142,33 @@ 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, ct, progress,
elapsed => _ = _broadcaster.OperationProgress(task.Id, PhaseVerifying, (int)elapsed.TotalSeconds, 0));
if (failed is not null) return failed;
}
return await ProgressReporter.RunAsync(
_pullRequests.PublishAsync(task, list, wt, ranVerify ? verifyCommand : null, ct),
ProgressReportInterval, progress, "push + draft PR running");
}
/// <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