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_KeptWorktree_OpensPrInsteadOfMergingLocally() { if (!GitRepoFixture.IsGitAvailable()) return; await SetUpWorktree(); using (var ctx = _db.CreateContext()) await new WorktreeRepository(ctx).SetStateAsync("t1", WorktreeState.Kept); var mainBefore = GitRepoFixture.RunGit(_repo.RepoDir, "rev-parse", "main").Trim(); var r = await Merge(Ado()).ApproveAndMergeAsync("t1", "", default); Assert.Equal(TaskMergeService.StatusPrCreated, r.Status); Assert.Equal(mainBefore, GitRepoFixture.RunGit(_repo.RepoDir, "rev-parse", "main").Trim()); Assert.Equal(WorktreeState.Active, (await Rows()).Wt.State); } [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()); } }