diff --git a/src/ClaudeDo.Data/Wire.cs b/src/ClaudeDo.Data/Wire.cs index fda7b5b5..714ffad4 100644 --- a/src/ClaudeDo.Data/Wire.cs +++ b/src/ClaudeDo.Data/Wire.cs @@ -81,7 +81,7 @@ public record PlanningMergeConflictStateDto(string PlanningTaskId, string Subtas public record PendingQuestionDto(string TaskId, string QuestionId, string Question); -public record MergeResultDto(string Status, IReadOnlyList ConflictFiles, string? ErrorMessage); +public record MergeResultDto(string Status, IReadOnlyList ConflictFiles, string? ErrorMessage, string? PullRequestUrl = null); public record BaseDirtyWarningDto(int ModifiedCount, int UntrackedCount); @@ -93,7 +93,7 @@ public record MergePreviewDto( public record MergeTargetsDto( string DefaultBranch, IReadOnlyList LocalBranches, string DefaultCommitMessage, - bool TargetDirty = false); + bool TargetDirty = false, bool IsCompanyRepo = false); public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList Files); diff --git a/src/ClaudeDo.Worker/AzureDevOps/PullRequestPublisher.cs b/src/ClaudeDo.Worker/AzureDevOps/PullRequestPublisher.cs new file mode 100644 index 00000000..02277496 --- /dev/null +++ b/src/ClaudeDo.Worker/AzureDevOps/PullRequestPublisher.cs @@ -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; + +/// +/// 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. +/// +public sealed class PullRequestPublisher +{ + /// Web front of the Bandel ticket system; the API base URL (an IP) is not browsable. + public const string TicketWebBase = "https://ticket.fb-tuning.local"; + + private readonly GitService _git; + private readonly AzureDevOpsClient _azdo; + private readonly IDbContextFactory _dbFactory; + private readonly HubBroadcaster _broadcaster; + private readonly WorkerConfig _cfg; + private readonly ILogger _logger; + private readonly TicketClientFactory? _tickets; + + public PullRequestPublisher( + GitService git, AzureDevOpsClient azdo, IDbContextFactory dbFactory, + HubBroadcaster broadcaster, WorkerConfig cfg, ILogger logger, + TicketClientFactory? tickets = null) + { + _git = git; + _azdo = azdo; + _dbFactory = dbFactory; + _broadcaster = broadcaster; + _cfg = cfg; + _logger = logger; + _tickets = tickets; + } + + /// Live check of origin against company_remote_pattern. + public Task IsCompanyRepoAsync(string workingDir, CancellationToken ct) => + CompanyRepo.IsCompanyAsync(_git, workingDir, _cfg.CompanyRemotePattern, ct); + + /// Why a PR can't be published from , or null — checked before the verify run so a missing PAT doesn't cost a build. + public async Task 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; + } + + /// Push + draft PR; is the command that passed (null = none ran). + public async Task 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(), null, url); + } + + /// <type>(<list-slug>): <title>, Resolves #<ticket>. + 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}"; + } + + /// Ticket link (plain Resolves #id without a project id), task description, and only what really ran. + 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 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); + } + + /// + /// The project an item lives in: a ticket's parent is its project, a task's parent is a ticket — so walk + /// up until parentTicketID is null (live-checked 2026-10-01). Null when unknown or unreachable. + /// + private async Task 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(), reason); +} diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index 0b036471..57e41932 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -636,7 +636,10 @@ public sealed class ExternalMcpService "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 " + "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 ReviewTask( string taskId, [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 " + "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) { if (leaveConflictsInTree) diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 54c319dd..69a52302 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -502,7 +502,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub => HubGuard(async () => { 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 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); if (r.Status == TaskMergeService.StatusBlocked) 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) diff --git a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs index 00481abb..efa2662b 100644 --- a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs +++ b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs @@ -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 ConflictFiles, - string? ErrorMessage); + string? ErrorMessage, + string? PullRequestUrl = null); public sealed record MergeTargets( string DefaultBranch, IReadOnlyList 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 _logger; private readonly UnitBranchService? _unitBranch; + private readonly PullRequestPublisher? _pullRequests; public TaskMergeService( IDbContextFactory dbFactory, @@ -126,7 +135,8 @@ public sealed class TaskMergeService ITaskStateService state, IVerifyCommandRunner verify, ILogger 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); } /// Leeres Ziel → list_config.base_branch, sonst der ausgecheckte Branch des Main-Checkouts. @@ -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); } + /// + /// 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 — skipVerify does not apply), then push + draft PR. Red verify + /// pushes nothing. Status, worktree state and ticket stay as they are. + /// + private async Task ApproveCompanyAsync( + TaskEntity task, ListEntity list, WorktreeEntity wt, string? verifyCommand, CancellationToken ct, + IProgress? 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"); + } + /// /// Redoes a merge that undid (or that predates it, if the branch /// was already merged before its worktree reached ), by diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index ef037935..dfc9bd35 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -257,6 +257,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHttpClient("azdo", c => c.Timeout = TimeSpan.FromSeconds(30)); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); if (cfg.OnlineInbox.Enabled) { diff --git a/tests/ClaudeDo.Worker.Tests/AzureDevOps/CompanyApproveTests.cs b/tests/ClaudeDo.Worker.Tests/AzureDevOps/CompanyApproveTests.cs new file mode 100644 index 00000000..50fb250d --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/AzureDevOps/CompanyApproveTests.cs @@ -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; + +/// +/// 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 pushInsteadOf sends the push into a local bare repo. HTTP stubbed. +/// +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 { } + } + + /// Worktree with one commit, created while the repo is still private (no fetch), then origin turns ADO. + 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.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.Instance); + } + var publisher = new PullRequestPublisher(_git, azdo, _db.CreateFactory(), _broadcaster, _cfg, + NullLogger.Instance, ticketClients); + return new TaskMergeService(_db.CreateFactory(), _git, _broadcaster, TaskStateServiceBuilder.Build(_db.CreateFactory()).State, + _verify, NullLogger.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()); + Assert.Equal("Resolves #412\n\nScanner-Parität\n\nGeprüft: `dotnet test` grün", body["description"]!.GetValue()); + Assert.Equal("refs/heads/main", body["targetRefName"]!.GetValue()); + Assert.True(body["isDraft"]!.GetValue()); + } + + [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(); + 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()); + } +}