feat(worker): approve in a company repo verifies in the worktree, pushes and opens a draft PR
This commit is contained in:
@@ -81,7 +81,7 @@ public record PlanningMergeConflictStateDto(string PlanningTaskId, string Subtas
|
|||||||
|
|
||||||
public record PendingQuestionDto(string TaskId, string QuestionId, string Question);
|
public record PendingQuestionDto(string TaskId, string QuestionId, string Question);
|
||||||
|
|
||||||
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
|
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage, string? PullRequestUrl = null);
|
||||||
|
|
||||||
public record BaseDirtyWarningDto(int ModifiedCount, int UntrackedCount);
|
public record BaseDirtyWarningDto(int ModifiedCount, int UntrackedCount);
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ public record MergePreviewDto(
|
|||||||
|
|
||||||
public record MergeTargetsDto(
|
public record MergeTargetsDto(
|
||||||
string DefaultBranch, IReadOnlyList<string> LocalBranches, string DefaultCommitMessage,
|
string DefaultBranch, IReadOnlyList<string> LocalBranches, string DefaultCommitMessage,
|
||||||
bool TargetDirty = false);
|
bool TargetDirty = false, bool IsCompanyRepo = false);
|
||||||
|
|
||||||
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
|
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
using System.Text;
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Git;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Config;
|
||||||
|
using ClaudeDo.Worker.Git;
|
||||||
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using ClaudeDo.Worker.Lifecycle;
|
||||||
|
using ClaudeDo.Worker.Runner;
|
||||||
|
using ClaudeDo.Worker.Tickets;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.AzureDevOps;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The company-repo end of a review: push the unit branch and find-or-create its draft PR on Azure DevOps.
|
||||||
|
/// Never touches task status or worktree state — the task stays WaitingForReview until the ADO merge.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PullRequestPublisher
|
||||||
|
{
|
||||||
|
/// <summary>Web front of the Bandel ticket system; the API base URL (an IP) is not browsable.</summary>
|
||||||
|
public const string TicketWebBase = "https://ticket.fb-tuning.local";
|
||||||
|
|
||||||
|
private readonly GitService _git;
|
||||||
|
private readonly AzureDevOpsClient _azdo;
|
||||||
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
|
private readonly HubBroadcaster _broadcaster;
|
||||||
|
private readonly WorkerConfig _cfg;
|
||||||
|
private readonly ILogger<PullRequestPublisher> _logger;
|
||||||
|
private readonly TicketClientFactory? _tickets;
|
||||||
|
|
||||||
|
public PullRequestPublisher(
|
||||||
|
GitService git, AzureDevOpsClient azdo, IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
|
HubBroadcaster broadcaster, WorkerConfig cfg, ILogger<PullRequestPublisher> logger,
|
||||||
|
TicketClientFactory? tickets = null)
|
||||||
|
{
|
||||||
|
_git = git;
|
||||||
|
_azdo = azdo;
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_broadcaster = broadcaster;
|
||||||
|
_cfg = cfg;
|
||||||
|
_logger = logger;
|
||||||
|
_tickets = tickets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Live check of origin against <c>company_remote_pattern</c>.</summary>
|
||||||
|
public Task<bool> IsCompanyRepoAsync(string workingDir, CancellationToken ct) =>
|
||||||
|
CompanyRepo.IsCompanyAsync(_git, workingDir, _cfg.CompanyRemotePattern, ct);
|
||||||
|
|
||||||
|
/// <summary>Why a PR can't be published from <paramref name="workingDir"/>, or null — checked before the verify run so a missing PAT doesn't cost a build.</summary>
|
||||||
|
public async Task<string?> CheckReadyAsync(string workingDir, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_azdo.Pat))
|
||||||
|
return "Kein Azure-DevOps-PAT hinterlegt (Einstellungen → Externe Dienste) — nichts gepusht.";
|
||||||
|
var remote = await _git.GetRemoteUrlAsync(workingDir, "origin", ct);
|
||||||
|
return AzureDevOpsClient.ParseRemote(remote) is null
|
||||||
|
? $"origin ist kein https-Remote eines Azure DevOps Servers: {remote}"
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Push + draft PR; <paramref name="verifyCommand"/> is the command that passed (null = none ran).</summary>
|
||||||
|
public async Task<MergeResult> PublishAsync(
|
||||||
|
TaskEntity task, ListEntity list, WorktreeEntity wt, string? verifyCommand, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var workingDir = list.WorkingDir!;
|
||||||
|
if (await CheckReadyAsync(workingDir, ct) is { } notReady) return Blocked(notReady);
|
||||||
|
var pat = _azdo.Pat!;
|
||||||
|
var repo = AzureDevOpsClient.ParseRemote(await _git.GetRemoteUrlAsync(workingDir, "origin", ct))!;
|
||||||
|
|
||||||
|
var (pushExit, pushErr) = await _git.PushBranchAsync(wt.Path, wt.BranchName, AzureDevOpsClient.GitAuthEnv(pat), ct);
|
||||||
|
if (pushExit != 0) return Blocked($"push rejected: {pushErr}");
|
||||||
|
|
||||||
|
string url;
|
||||||
|
bool created;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var target = await ResolveTargetAsync(task.ListId, repo, ct);
|
||||||
|
if (target == wt.BranchName)
|
||||||
|
return Blocked($"gepusht, PR fehlgeschlagen: Quell- und Ziel-Branch sind beide '{target}'.");
|
||||||
|
var ticketId = TicketStatusMap.ParseBandelTicketId(task.TicketRef);
|
||||||
|
var title = BuildTitle(task, list, ticketId);
|
||||||
|
var description = BuildDescription(task, ticketId, await ResolveProjectIdAsync(ticketId, ct), verifyCommand);
|
||||||
|
(url, created) = await _azdo.EnsureDraftPullRequestAsync(repo, wt.BranchName, target, title, description, ct);
|
||||||
|
}
|
||||||
|
catch (AzureDevOpsException ex)
|
||||||
|
{
|
||||||
|
return Blocked($"gepusht, PR fehlgeschlagen: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var ctx = _dbFactory.CreateDbContext())
|
||||||
|
await new WorktreeRepository(ctx).SetPullRequestUrlAsync(task.Id, url, ct);
|
||||||
|
await _broadcaster.WorktreeUpdated(task.Id);
|
||||||
|
_logger.LogInformation("Task {task_id}: branch {branch} pushed, draft PR {pr_url} ({pr_state})",
|
||||||
|
task.Id, wt.BranchName, url, created ? "created" : "reused");
|
||||||
|
|
||||||
|
return new MergeResult(created ? TaskMergeService.StatusPrCreated : TaskMergeService.StatusPrUpdated,
|
||||||
|
Array.Empty<string>(), null, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary><c><type>(<list-slug>): <title>, Resolves #<ticket></c>.</summary>
|
||||||
|
public static string BuildTitle(TaskEntity task, ListEntity list, int? ticketId)
|
||||||
|
{
|
||||||
|
var type = string.IsNullOrWhiteSpace(task.CommitType) ? CommitTypeRegistry.DefaultType : task.CommitType.Trim();
|
||||||
|
var slug = CommitMessageBuilder.ToSlug(list.Name);
|
||||||
|
var scope = slug.Length == 0 ? "" : $"({slug})";
|
||||||
|
var title = $"{type}{scope}: {task.Title.Trim()}";
|
||||||
|
return ticketId is null ? title : $"{title}, Resolves #{ticketId}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ticket link (plain <c>Resolves #id</c> without a project id), task description, and only what really ran.</summary>
|
||||||
|
public static string BuildDescription(TaskEntity task, int? ticketId, int? projectId, string? verifyCommand)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
if (ticketId is { } id)
|
||||||
|
{
|
||||||
|
sb.Append(projectId is { } pid
|
||||||
|
? $"Resolves [#{id}]({TicketWebBase}/board/project/{pid}/ticket/{id})"
|
||||||
|
: $"Resolves #{id}");
|
||||||
|
sb.Append("\n\n");
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(task.Description))
|
||||||
|
sb.Append(task.Description.Trim()).Append("\n\n");
|
||||||
|
sb.Append(verifyCommand is null ? "Geprüft: nichts automatisch" : $"Geprüft: `{verifyCommand}` grün");
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> ResolveTargetAsync(string listId, AzdoRepo repo, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var ctx = _dbFactory.CreateDbContext();
|
||||||
|
var baseBranch = await ctx.ListConfigs.AsNoTracking()
|
||||||
|
.Where(c => c.ListId == listId)
|
||||||
|
.Select(c => c.BaseBranch)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
return baseBranch.NullIfBlank() ?? await _azdo.GetDefaultBranchAsync(repo, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The project an item lives in: a ticket's parent is its project, a task's parent is a ticket — so walk
|
||||||
|
/// up until <c>parentTicketID</c> is null (live-checked 2026-10-01). Null when unknown or unreachable.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<int?> ResolveProjectIdAsync(int? ticketId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (ticketId is null || _tickets?.Create() is not { } client) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var id = ticketId.Value;
|
||||||
|
for (var hop = 0; hop < 3; hop++)
|
||||||
|
{
|
||||||
|
var detail = await client.GetTicketAsync(id, ct);
|
||||||
|
if (detail.ParentTicketId is not { } parent) return hop == 0 ? null : id;
|
||||||
|
id = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (TicketApiException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Project of ticket {ticket_id} unknown, PR links no ticket: {reason}", ticketId, ex.Message);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MergeResult Blocked(string reason) =>
|
||||||
|
new(TaskMergeService.StatusBlocked, Array.Empty<string>(), reason);
|
||||||
|
}
|
||||||
+8
-1
@@ -636,7 +636,10 @@ public sealed class ExternalMcpService
|
|||||||
"means the merge stopped on conflicts, with the files listed. emptyChildren (parent approve only) lists " +
|
"means the merge stopped on conflicts, with the files listed. emptyChildren (parent approve only) lists " +
|
||||||
"the Done children about to be unit-merged whose own review range contributed nothing (e.g. a child that " +
|
"the Done children about to be unit-merged whose own review range contributed nothing (e.g. a child that " +
|
||||||
"reported CLAUDEDO_BLOCKED and committed no code) — check it before trusting that every child actually " +
|
"reported CLAUDEDO_BLOCKED and committed no code) — check it before trusting that every child actually " +
|
||||||
"delivered something." + McpToolDocs.LeanTaskRef + McpToolDocs.TaskNumberHint + McpToolDocs.ProgressHint)]
|
"delivered something. In a company repo (origin on the Azure DevOps Server) approve does not merge: it runs " +
|
||||||
|
"the verify command in the task's worktree (skipVerify is ignored), pushes the branch and opens or reuses a " +
|
||||||
|
"draft PR — mergeStatus 'pr_created'/'pr_updated', mergeMessage = the PR URL, the task stays WaitingForReview " +
|
||||||
|
"and needs no verify_merges." + McpToolDocs.LeanTaskRef + McpToolDocs.TaskNumberHint + McpToolDocs.ProgressHint)]
|
||||||
public async Task<ReviewTaskResult> ReviewTask(
|
public async Task<ReviewTaskResult> ReviewTask(
|
||||||
string taskId,
|
string taskId,
|
||||||
[Description("'approve', 'reject_rerun', 'reject_park' or 'cancel'.")]
|
[Description("'approve', 'reject_rerun', 'reject_park' or 'cancel'.")]
|
||||||
@@ -736,6 +739,10 @@ public sealed class ExternalMcpService
|
|||||||
mergeMessage = "merged; verify deferred — the task stays WaitingForReview until " +
|
mergeMessage = "merged; verify deferred — the task stays WaitingForReview until " +
|
||||||
"verify_merges (with its id) passes";
|
"verify_merges (with its id) passes";
|
||||||
}
|
}
|
||||||
|
else if (r.Status is TaskMergeService.StatusPrCreated or TaskMergeService.StatusPrUpdated)
|
||||||
|
{
|
||||||
|
mergeMessage = r.PullRequestUrl;
|
||||||
|
}
|
||||||
else if (r.Status == TaskMergeService.StatusConflict)
|
else if (r.Status == TaskMergeService.StatusConflict)
|
||||||
{
|
{
|
||||||
if (leaveConflictsInTree)
|
if (leaveConflictsInTree)
|
||||||
|
|||||||
@@ -502,7 +502,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
=> HubGuard(async () =>
|
=> HubGuard(async () =>
|
||||||
{
|
{
|
||||||
var t = await _mergeService.GetTargetsAsync(taskId, CancellationToken.None);
|
var t = await _mergeService.GetTargetsAsync(taskId, CancellationToken.None);
|
||||||
return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches, t.DefaultCommitMessage, t.TargetDirty);
|
return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches, t.DefaultCommitMessage, t.TargetDirty, t.IsCompanyRepo);
|
||||||
});
|
});
|
||||||
|
|
||||||
public Task<MergePreviewDto> PreviewMerge(string taskId, string targetBranch)
|
public Task<MergePreviewDto> PreviewMerge(string taskId, string targetBranch)
|
||||||
@@ -714,7 +714,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
var r = await _mergeService.ApproveAndMergeAsync(taskId, targetBranch ?? "", CancellationToken.None, autostash);
|
var r = await _mergeService.ApproveAndMergeAsync(taskId, targetBranch ?? "", CancellationToken.None, autostash);
|
||||||
if (r.Status == TaskMergeService.StatusBlocked)
|
if (r.Status == TaskMergeService.StatusBlocked)
|
||||||
throw new HubException(r.ErrorMessage ?? "approve failed");
|
throw new HubException(r.ErrorMessage ?? "approve failed");
|
||||||
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
|
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage, r.PullRequestUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
public async Task RejectReviewToQueue(string taskId, string feedback)
|
public async Task RejectReviewToQueue(string taskId, string feedback)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using ClaudeDo.Data;
|
|||||||
using ClaudeDo.Data.Git;
|
using ClaudeDo.Data.Git;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.AzureDevOps;
|
||||||
using ClaudeDo.Worker.Hub;
|
using ClaudeDo.Worker.Hub;
|
||||||
using ClaudeDo.Worker.Runner;
|
using ClaudeDo.Worker.Runner;
|
||||||
using ClaudeDo.Worker.State;
|
using ClaudeDo.Worker.State;
|
||||||
@@ -15,13 +16,15 @@ namespace ClaudeDo.Worker.Lifecycle;
|
|||||||
public sealed record MergeResult(
|
public sealed record MergeResult(
|
||||||
string Status,
|
string Status,
|
||||||
IReadOnlyList<string> ConflictFiles,
|
IReadOnlyList<string> ConflictFiles,
|
||||||
string? ErrorMessage);
|
string? ErrorMessage,
|
||||||
|
string? PullRequestUrl = null);
|
||||||
|
|
||||||
public sealed record MergeTargets(
|
public sealed record MergeTargets(
|
||||||
string DefaultBranch,
|
string DefaultBranch,
|
||||||
IReadOnlyList<string> LocalBranches,
|
IReadOnlyList<string> LocalBranches,
|
||||||
string DefaultCommitMessage,
|
string DefaultCommitMessage,
|
||||||
bool TargetDirty = false);
|
bool TargetDirty = false,
|
||||||
|
bool IsCompanyRepo = false);
|
||||||
|
|
||||||
// VerifyExitCode/VerifyDurationMs/VerifyOutputTail are only populated when the caller asked for
|
// 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
|
// 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.
|
// autostash: true) without matching on prose.
|
||||||
public const string StatusDirtyTarget = "dirty_target";
|
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 StatusReverted = "reverted";
|
||||||
public const string StatusConflictAborted = "conflict_aborted";
|
public const string StatusConflictAborted = "conflict_aborted";
|
||||||
|
|
||||||
@@ -118,6 +126,7 @@ public sealed class TaskMergeService
|
|||||||
private readonly IVerifyCommandRunner _verify;
|
private readonly IVerifyCommandRunner _verify;
|
||||||
private readonly ILogger<TaskMergeService> _logger;
|
private readonly ILogger<TaskMergeService> _logger;
|
||||||
private readonly UnitBranchService? _unitBranch;
|
private readonly UnitBranchService? _unitBranch;
|
||||||
|
private readonly PullRequestPublisher? _pullRequests;
|
||||||
|
|
||||||
public TaskMergeService(
|
public TaskMergeService(
|
||||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
@@ -126,7 +135,8 @@ public sealed class TaskMergeService
|
|||||||
ITaskStateService state,
|
ITaskStateService state,
|
||||||
IVerifyCommandRunner verify,
|
IVerifyCommandRunner verify,
|
||||||
ILogger<TaskMergeService> logger,
|
ILogger<TaskMergeService> logger,
|
||||||
UnitBranchService? unitBranch = null)
|
UnitBranchService? unitBranch = null,
|
||||||
|
PullRequestPublisher? pullRequests = null)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
_git = git;
|
_git = git;
|
||||||
@@ -135,6 +145,7 @@ public sealed class TaskMergeService
|
|||||||
_verify = verify;
|
_verify = verify;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_unitBranch = unitBranch;
|
_unitBranch = unitBranch;
|
||||||
|
_pullRequests = pullRequests;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<(TaskEntity Task, ListEntity List, WorktreeEntity? Worktree, string? VerifyCommand)> LoadMergeContextAsync(
|
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 current = await ResolveTargetAsync(list, null, ct);
|
||||||
var branches = await _git.ListLocalBranchesAsync(list.WorkingDir, ct);
|
var branches = await _git.ListLocalBranchesAsync(list.WorkingDir, ct);
|
||||||
var dirty = await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, 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>
|
/// <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)
|
if (wt is not null && wt.State == WorktreeState.Kept)
|
||||||
{
|
{
|
||||||
// Kept means either RevertMergeAsync undid a previous merge, or the user manually
|
// 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);
|
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>
|
/// <summary>
|
||||||
/// Redoes a merge that <see cref="RevertMergeAsync"/> undid (or that predates it, if the branch
|
/// 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
|
/// was already merged before its worktree reached <see cref="WorktreeState.Kept"/>), by
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ builder.Services.AddSingleton<TicketStatusSync>();
|
|||||||
builder.Services.AddSingleton<TicketImportService>();
|
builder.Services.AddSingleton<TicketImportService>();
|
||||||
builder.Services.AddHttpClient("azdo", c => c.Timeout = TimeSpan.FromSeconds(30));
|
builder.Services.AddHttpClient("azdo", c => c.Timeout = TimeSpan.FromSeconds(30));
|
||||||
builder.Services.AddSingleton<AzureDevOpsClient>();
|
builder.Services.AddSingleton<AzureDevOpsClient>();
|
||||||
|
builder.Services.AddSingleton<PullRequestPublisher>();
|
||||||
|
|
||||||
if (cfg.OnlineInbox.Enabled)
|
if (cfg.OnlineInbox.Enabled)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using ClaudeDo.Data.Git;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.AzureDevOps;
|
||||||
|
using ClaudeDo.Worker.Config;
|
||||||
|
using ClaudeDo.Worker.Git;
|
||||||
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using ClaudeDo.Worker.Lifecycle;
|
||||||
|
using ClaudeDo.Worker.Runner;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using ClaudeDo.Worker.Tests.Services;
|
||||||
|
using ClaudeDo.Worker.Tests.Tickets;
|
||||||
|
using ClaudeDo.Worker.Tickets;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.AzureDevOps;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Spec 2026-10-01 section 3: approve in a company repo = verify in the worktree, push, draft PR. origin is an
|
||||||
|
/// ADO-shaped URL so it parses, and <c>pushInsteadOf</c> sends the push into a local bare repo. HTTP stubbed.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CompanyApproveTests : IDisposable
|
||||||
|
{
|
||||||
|
private const string Remote = "http://ado.test/coll/proj/_git/Repo";
|
||||||
|
|
||||||
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly GitRepoFixture _repo = new();
|
||||||
|
private readonly string _tempDir = Path.Combine(Path.GetTempPath(), $"cd_capr_{Guid.NewGuid():N}");
|
||||||
|
private readonly string _bare;
|
||||||
|
private readonly string _listName = $"ca{Guid.NewGuid():N}"[..12];
|
||||||
|
private readonly WorkerConfig _cfg;
|
||||||
|
private readonly GitService _git = new();
|
||||||
|
private readonly FakeVerifyCommandRunner _verify = new();
|
||||||
|
private readonly HubBroadcaster _broadcaster = new(new CapturingHubContext());
|
||||||
|
private WorktreeContext _wt = null!;
|
||||||
|
|
||||||
|
public CompanyApproveTests()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_tempDir);
|
||||||
|
GitRepoFixture.RunGit(_repo.RepoDir, "branch", "-m", "main");
|
||||||
|
_bare = Path.Combine(_tempDir, "origin.git").Replace('\\', '/');
|
||||||
|
GitRepoFixture.RunGit(_tempDir, "clone", "--bare", "-q", _repo.RepoDir, _bare);
|
||||||
|
_cfg = new WorkerConfig { SandboxRoot = _tempDir, LogRoot = _tempDir, WorktreeRootStrategy = "sibling" };
|
||||||
|
|
||||||
|
using var ctx = _db.CreateContext();
|
||||||
|
ctx.Lists.Add(new ListEntity { Id = "l1", Name = _listName, WorkingDir = _repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
||||||
|
ctx.ListConfigs.Add(new ListConfigEntity { ListId = "l1", VerifyCommand = "dotnet test" });
|
||||||
|
ctx.Tasks.Add(new TaskEntity { Id = "t1", ListId = "l1", Title = "Scan erfassen", Number = 1, CommitType = "feat",
|
||||||
|
Description = "Scanner-Parität", TicketRef = "bandel:412", Status = TaskStatus.WaitingForReview, CreatedAt = DateTime.UtcNow });
|
||||||
|
ctx.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try { GitRepoFixture.RunGit(_repo.RepoDir, "worktree", "remove", "--force", _wt.WorktreePath); } catch { }
|
||||||
|
try { Directory.Delete(Path.Combine(WorktreeRootResolver.ResolveSiblingRoot(_repo.RepoDir), ".claudedo-worktrees", _listName), true); } catch { }
|
||||||
|
_db.Dispose();
|
||||||
|
_repo.Dispose();
|
||||||
|
try { GitRepoFixture.ForceDeleteDirectory(_tempDir); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Worktree with one commit, created while the repo is still private (no fetch), then origin turns ADO.</summary>
|
||||||
|
private async Task SetUpWorktree()
|
||||||
|
{
|
||||||
|
TaskEntity task; ListEntity list;
|
||||||
|
using (var ctx = _db.CreateContext()) { task = ctx.Tasks.Single(); list = ctx.Lists.Single(); }
|
||||||
|
var manager = new WorktreeManager(_git, _db.CreateFactory(), _cfg, NullLogger<WorktreeManager>.Instance);
|
||||||
|
_wt = await manager.CreateAsync(task, list, default);
|
||||||
|
File.WriteAllText(Path.Combine(_wt.WorktreePath, "scan.txt"), "scan");
|
||||||
|
GitRepoFixture.RunGit(_wt.WorktreePath, "add", "-A");
|
||||||
|
GitRepoFixture.RunGit(_wt.WorktreePath, "commit", "-q", "-m", "scan");
|
||||||
|
|
||||||
|
GitRepoFixture.RunGit(_repo.RepoDir, "remote", "add", "origin", Remote);
|
||||||
|
GitRepoFixture.RunGit(_repo.RepoDir, "config", $"url.{_bare}.pushInsteadOf", Remote);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TaskMergeService Merge(StubHandler handler, string? pat = "geheim", bool tickets = false)
|
||||||
|
{
|
||||||
|
var azdo = new AzureDevOpsClient(new StubHttpClientFactory(handler), AzureDevOpsClientTests.PatStore(_tempDir, pat));
|
||||||
|
TicketClientFactory? ticketClients = null;
|
||||||
|
if (tickets)
|
||||||
|
{
|
||||||
|
var ticketPat = new TicketPatStore(new DpapiTokenStore(Path.Combine(_tempDir, "ticket.pat")));
|
||||||
|
ticketPat.Store.Save("tsp_x");
|
||||||
|
var config = new TicketSystemConfig(new WorkerConfig { TicketApiBaseUrl = "http://tickets.test" }, ticketPat);
|
||||||
|
ticketClients = new TicketClientFactory(config, new StubHttpClientFactory(handler), NullLogger<BandelTicketClient>.Instance);
|
||||||
|
}
|
||||||
|
var publisher = new PullRequestPublisher(_git, azdo, _db.CreateFactory(), _broadcaster, _cfg,
|
||||||
|
NullLogger<PullRequestPublisher>.Instance, ticketClients);
|
||||||
|
return new TaskMergeService(_db.CreateFactory(), _git, _broadcaster, TaskStateServiceBuilder.Build(_db.CreateFactory()).State,
|
||||||
|
_verify, NullLogger<TaskMergeService>.Instance, pullRequests: publisher);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StubHandler Ado(string existingPrs = "[]", HttpStatusCode postStatus = HttpStatusCode.Created) => new(req =>
|
||||||
|
req.RequestUri!.Host == "tickets.test"
|
||||||
|
? (HttpStatusCode.OK, req.RequestUri.AbsolutePath switch
|
||||||
|
{
|
||||||
|
// Task 412 → ticket 400 → project 393 (live shape: a project has no parent).
|
||||||
|
"/api/ticket/412" => Envelope("""{"id":412,"parentTicketID":400}"""),
|
||||||
|
"/api/ticket/400" => Envelope("""{"id":400,"parentTicketID":393}"""),
|
||||||
|
_ => Envelope("""{"id":393,"parentTicketID":null}"""),
|
||||||
|
})
|
||||||
|
: req.RequestUri!.AbsolutePath.EndsWith("/pullrequests")
|
||||||
|
? req.Method == HttpMethod.Get
|
||||||
|
? (HttpStatusCode.OK, $$"""{"value":{{existingPrs}}}""")
|
||||||
|
: (postStatus, postStatus == HttpStatusCode.Created ? """{"pullRequestId":5}""" : """{"message":"TF0: server error"}""")
|
||||||
|
: (HttpStatusCode.OK, """{"defaultBranch":"refs/heads/main"}"""));
|
||||||
|
|
||||||
|
private static string Envelope(string data) => $$"""{"success":true,"data":{{data}},"message":"ok"}""";
|
||||||
|
|
||||||
|
private bool BareHasBranch() =>
|
||||||
|
GitRepoFixture.RunGit(_bare, "branch", "--list", _wt.BranchName).Trim().Length > 0;
|
||||||
|
|
||||||
|
private async Task<(TaskEntity Task, WorktreeEntity Wt)> Rows()
|
||||||
|
{
|
||||||
|
using var ctx = _db.CreateContext();
|
||||||
|
return ((await new TaskRepository(ctx).GetByIdAsync("t1"))!, (await new WorktreeRepository(ctx).GetByTaskIdAsync("t1"))!);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approve_VerifiesInWorktree_PushesAndCreatesDraftPr_TaskStaysInReview()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
await SetUpWorktree();
|
||||||
|
var ado = Ado();
|
||||||
|
|
||||||
|
var r = await Merge(ado).ApproveAndMergeAsync("t1", "", default);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.StatusPrCreated, r.Status);
|
||||||
|
Assert.Equal($"{Remote}/pullrequest/5", r.PullRequestUrl);
|
||||||
|
Assert.Equal(_wt.WorktreePath, _verify.CapturedWorkingDir);
|
||||||
|
Assert.True(BareHasBranch());
|
||||||
|
var (task, wt) = await Rows();
|
||||||
|
Assert.Equal(TaskStatus.WaitingForReview, task.Status);
|
||||||
|
Assert.Equal(WorktreeState.Active, wt.State);
|
||||||
|
Assert.Equal(r.PullRequestUrl, wt.PullRequestUrl);
|
||||||
|
|
||||||
|
var body = JsonNode.Parse(ado.Bodies.Last())!;
|
||||||
|
Assert.Equal($"feat({_listName}): Scan erfassen, Resolves #412", body["title"]!.GetValue<string>());
|
||||||
|
Assert.Equal("Resolves #412\n\nScanner-Parität\n\nGeprüft: `dotnet test` grün", body["description"]!.GetValue<string>());
|
||||||
|
Assert.Equal("refs/heads/main", body["targetRefName"]!.GetValue<string>());
|
||||||
|
Assert.True(body["isDraft"]!.GetValue<bool>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approve_LinksTicketUnderItsProject_WalkingUpFromATask()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
await SetUpWorktree();
|
||||||
|
var ado = Ado();
|
||||||
|
|
||||||
|
await Merge(ado, tickets: true).ApproveAndMergeAsync("t1", "", default);
|
||||||
|
|
||||||
|
var description = JsonNode.Parse(ado.Bodies.Last())!["description"]!.GetValue<string>();
|
||||||
|
Assert.StartsWith("Resolves [#412](https://ticket.fb-tuning.local/board/project/393/ticket/412)", description);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approve_ReusesActivePr_AsUpdate()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
await SetUpWorktree();
|
||||||
|
var ado = Ado(existingPrs: """[{"pullRequestId":9}]""");
|
||||||
|
|
||||||
|
var r = await Merge(ado).ApproveAndMergeAsync("t1", "", default);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.StatusPrUpdated, r.Status);
|
||||||
|
Assert.EndsWith("/pullrequest/9", r.PullRequestUrl);
|
||||||
|
Assert.DoesNotContain(ado.Requests, q => q.Method == HttpMethod.Post);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approve_RedVerify_PushesNothing()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
await SetUpWorktree();
|
||||||
|
_verify.Result = new VerifyCommandResult(1, false, "boom");
|
||||||
|
var ado = Ado();
|
||||||
|
|
||||||
|
var r = await Merge(ado).ApproveAndMergeAsync("t1", "", default);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.StatusVerifyFailed, r.Status);
|
||||||
|
Assert.False(BareHasBranch());
|
||||||
|
Assert.Empty(ado.Requests);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approve_PrFailsAfterPush_SaysSoHonestly()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
await SetUpWorktree();
|
||||||
|
|
||||||
|
var r = await Merge(Ado(postStatus: HttpStatusCode.InternalServerError)).ApproveAndMergeAsync("t1", "", default);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.StatusBlocked, r.Status);
|
||||||
|
Assert.StartsWith("gepusht, PR fehlgeschlagen", r.ErrorMessage);
|
||||||
|
Assert.True(BareHasBranch());
|
||||||
|
Assert.Null((await Rows()).Wt.PullRequestUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approve_SkipVerifyIsIgnored()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
await SetUpWorktree();
|
||||||
|
|
||||||
|
var r = await Merge(Ado()).ApproveAndMergeAsync("t1", "", leaveConflictsInTree: false, default, skipVerify: true);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.StatusPrCreated, r.Status);
|
||||||
|
Assert.Equal(1, _verify.CallCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approve_WithoutPat_BlocksBeforeVerifyAndPush()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
await SetUpWorktree();
|
||||||
|
|
||||||
|
var r = await Merge(Ado(), pat: null).ApproveAndMergeAsync("t1", "", default);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.StatusBlocked, r.Status);
|
||||||
|
Assert.Contains("PAT", r.ErrorMessage);
|
||||||
|
Assert.Equal(0, _verify.CallCount);
|
||||||
|
Assert.False(BareHasBranch());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user