Merge branch 'claudedo/387-unit-branch-teil-3-firmen-modus-fetch-pu'
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
using ClaudeDo.Worker.AzureDevOps;
|
||||
using ClaudeDo.Worker.Tests.Tickets;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.AzureDevOps;
|
||||
|
||||
/// <summary>Port of pr-create.ps1: its -SelfTest cases plus the PR find-or-create flow, HTTP stubbed.</summary>
|
||||
public sealed class AzureDevOpsClientTests : IDisposable
|
||||
{
|
||||
private readonly string _dir = Path.Combine(Path.GetTempPath(), $"cd_azdo_{Guid.NewGuid():N}");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, true); } catch { }
|
||||
}
|
||||
|
||||
internal static AzureDevOpsPatStore PatStore(string dir, string? pat = "geheim")
|
||||
{
|
||||
var store = new AzureDevOpsPatStore(new DpapiTokenStore(Path.Combine(dir, "azdo.pat")));
|
||||
if (pat is not null) store.Store.Save(pat);
|
||||
return store;
|
||||
}
|
||||
|
||||
private AzureDevOpsClient Client(StubHandler handler, string? pat = "geheim") =>
|
||||
new(new StubHttpClientFactory(handler), PatStore(_dir, pat));
|
||||
|
||||
private static readonly AzdoRepo Repo = new("https://srv.local/Coll/Proj", "Repo", "https://srv.local/Coll/Proj/_git/Repo");
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://mika@srv.local/Coll/Proj/_git/Repo.git", "https://srv.local/Coll/Proj", "Repo")]
|
||||
[InlineData("https://srv.local/tfs/Coll/Proj/_git/Mein%20Repo", "https://srv.local/tfs/Coll/Proj", "Mein Repo")]
|
||||
public void ParseRemote_SelfTestCases(string remote, string apiBase, string repo)
|
||||
{
|
||||
var ctx = AzureDevOpsClient.ParseRemote(remote);
|
||||
|
||||
Assert.NotNull(ctx);
|
||||
Assert.Equal(apiBase, ctx!.ApiBase);
|
||||
Assert.Equal(repo, ctx.Repo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseRemote_StripsCredentialsFromWebUrl()
|
||||
{
|
||||
var ctx = AzureDevOpsClient.ParseRemote("https://mika:pw@srv.local/Coll/Proj/_git/Repo.git");
|
||||
Assert.Equal("https://srv.local/Coll/Proj/_git/Repo", ctx!.WebUrl);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://example.com/mika/repo.git")]
|
||||
[InlineData("C:/repos/coll/_git/repo")]
|
||||
[InlineData("")]
|
||||
public void ParseRemote_NonAdoRemote_IsNull(string remote) => Assert.Null(AzureDevOpsClient.ParseRemote(remote));
|
||||
|
||||
[Fact]
|
||||
public void AuthHeader_MatchesScript() => Assert.Equal("Basic OmdlaGVpbQ==", AzureDevOpsClient.AuthHeaderValue("geheim"));
|
||||
|
||||
[Fact]
|
||||
public void GitAuthEnv_PassesPatAsExtraHeaderAndNeverPrompts()
|
||||
{
|
||||
var env = AzureDevOpsClient.GitAuthEnv("geheim");
|
||||
|
||||
Assert.Equal("0", env["GIT_TERMINAL_PROMPT"]);
|
||||
Assert.Equal("never", env["GCM_INTERACTIVE"]);
|
||||
Assert.Equal("1", env["GIT_CONFIG_COUNT"]);
|
||||
Assert.Equal("http.extraHeader", env["GIT_CONFIG_KEY_0"]);
|
||||
Assert.Equal("Authorization: Basic OmdlaGVpbQ==", env["GIT_CONFIG_VALUE_0"]);
|
||||
Assert.False(AzureDevOpsClient.GitAuthEnv(null).ContainsKey("GIT_CONFIG_COUNT"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SupportedVersion_ReadsOnlyTheOutOfRangeError()
|
||||
{
|
||||
Assert.Equal("6.0", AzureDevOpsClient.SupportedVersion(
|
||||
"The requested REST API version of 7.1 is out of range for this server. The latest version this server supports is 6.0."));
|
||||
Assert.Null(AzureDevOpsClient.SupportedVersion("TF401019: irgendein anderer Fehler"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureDraftPullRequest_ReusesActivePr_WithoutPost()
|
||||
{
|
||||
var handler = new StubHandler(_ => (HttpStatusCode.OK, """{"value":[{"pullRequestId":42}],"count":1}"""));
|
||||
|
||||
var (url, created) = await Client(handler).EnsureDraftPullRequestAsync(Repo, "feature/1-x", "main", "t", "d", default);
|
||||
|
||||
Assert.False(created);
|
||||
Assert.Equal("https://srv.local/Coll/Proj/_git/Repo/pullrequest/42", url);
|
||||
Assert.Single(handler.Requests);
|
||||
Assert.Contains("searchCriteria.sourceRefName=refs%2Fheads%2Ffeature%2F1-x", handler.Requests[0].RequestUri!.Query);
|
||||
Assert.Contains("searchCriteria.status=active", handler.Requests[0].RequestUri!.Query);
|
||||
Assert.Equal("Basic OmdlaGVpbQ==", handler.Requests[0].Headers.Authorization!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureDraftPullRequest_CreatesDraftWhenNoneActive()
|
||||
{
|
||||
var handler = new StubHandler(req => req.Method == HttpMethod.Get
|
||||
? (HttpStatusCode.OK, """{"value":[],"count":0}""")
|
||||
: (HttpStatusCode.Created, """{"pullRequestId":7}"""));
|
||||
|
||||
var (url, created) = await Client(handler).EnsureDraftPullRequestAsync(Repo, "feature/1-x", "dev", "Titel", "Beschreibung", default);
|
||||
|
||||
Assert.True(created);
|
||||
Assert.EndsWith("/pullrequest/7", url);
|
||||
var body = JsonNode.Parse(handler.Bodies[1])!;
|
||||
Assert.True(body["isDraft"]!.GetValue<bool>());
|
||||
Assert.Equal("refs/heads/feature/1-x", body["sourceRefName"]!.GetValue<string>());
|
||||
Assert.Equal("refs/heads/dev", body["targetRefName"]!.GetValue<string>());
|
||||
Assert.Equal("Titel", body["title"]!.GetValue<string>());
|
||||
Assert.Equal("Beschreibung", body["description"]!.GetValue<string>());
|
||||
Assert.Equal("https://srv.local/Coll/Proj/_apis/git/repositories/Repo/pullrequests", handler.Requests[1].RequestUri!.GetLeftPart(UriPartial.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApiVersion_FallsBackToTheVersionTheServerNames()
|
||||
{
|
||||
var handler = new StubHandler(req => req.RequestUri!.Query.Contains("api-version=6.0")
|
||||
? (HttpStatusCode.BadRequest, """{"message":"The requested REST API version of 6.0 is out of range for this server. The latest version this server supports is 5.1."}""")
|
||||
: (HttpStatusCode.OK, """{"defaultBranch":"refs/heads/dev"}"""));
|
||||
|
||||
var branch = await Client(handler).GetDefaultBranchAsync(Repo, default);
|
||||
|
||||
Assert.Equal("dev", branch);
|
||||
Assert.Equal(2, handler.Requests.Count);
|
||||
Assert.Contains("api-version=5.1", handler.Requests[1].RequestUri!.Query);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestError_SurfacesStatusAndServerMessage()
|
||||
{
|
||||
var handler = new StubHandler(_ => (HttpStatusCode.Forbidden, """{"message":"TF401027: You need the Git 'PullRequestContribute' permission."}"""));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<AzureDevOpsException>(() => Client(handler).GetDefaultBranchAsync(Repo, default));
|
||||
|
||||
Assert.Contains("403", ex.Message);
|
||||
Assert.Contains("TF401027", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoPat_FailsBeforeAnyRequest()
|
||||
{
|
||||
var handler = new StubHandler(_ => (HttpStatusCode.OK, "{}"));
|
||||
|
||||
await Assert.ThrowsAsync<AzureDevOpsException>(() => Client(handler, pat: null).GetDefaultBranchAsync(Repo, default));
|
||||
Assert.Empty(handler.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RejectedPat_203LoginPage_IsAPatError()
|
||||
{
|
||||
var handler = new StubHandler(_ => ((HttpStatusCode)203, "<html>login</html>"));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<AzureDevOpsException>(() => Client(handler).GetDefaultBranchAsync(Repo, default));
|
||||
Assert.Contains("PAT", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class StubHttpClientFactory(StubHandler handler) : IHttpClientFactory
|
||||
{
|
||||
public HttpClient CreateClient(string name) => new(handler, disposeHandler: false);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
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_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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.Git;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.AzureDevOps;
|
||||
|
||||
/// <summary>Spec 2026-10-01 "Basis aktualisieren": a company unit root starts from a fetched origin/<basis>. Real git, local bare repo as origin.</summary>
|
||||
public sealed class CompanyFetchBaseTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
private readonly GitRepoFixture _repo = new();
|
||||
private readonly string _tempDir = Path.Combine(Path.GetTempPath(), $"cd_fetch_{Guid.NewGuid():N}");
|
||||
private readonly string _bare;
|
||||
private readonly string _listName = $"fb{Guid.NewGuid():N}"[..12];
|
||||
private readonly WorkerConfig _cfg;
|
||||
|
||||
public CompanyFetchBaseTests()
|
||||
{
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
GitRepoFixture.RunGit(_repo.RepoDir, "branch", "-m", "main");
|
||||
_bare = Path.Combine(_tempDir, "cdbare_origin.git");
|
||||
GitRepoFixture.RunGit(_tempDir, "clone", "--bare", "-q", _repo.RepoDir, _bare);
|
||||
GitRepoFixture.RunGit(_repo.RepoDir, "remote", "add", "origin", _bare);
|
||||
_cfg = new WorkerConfig { SandboxRoot = _tempDir, LogRoot = _tempDir, WorktreeRootStrategy = "sibling", CompanyRemotePattern = "cdbare_" };
|
||||
|
||||
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", BaseBranch = "main" });
|
||||
ctx.Tasks.Add(new TaskEntity { Id = "t1", ListId = "l1", Title = "Root", Number = 1, CommitType = "feat",
|
||||
Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow });
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(Path.Combine(WorktreeRootResolver.ResolveSiblingRoot(_repo.RepoDir), ".claudedo-worktrees", _listName), true); } catch { }
|
||||
_db.Dispose();
|
||||
_repo.Dispose();
|
||||
try { GitRepoFixture.ForceDeleteDirectory(_tempDir); } catch { }
|
||||
}
|
||||
|
||||
/// <summary>Pushes one commit to the bare origin from a scratch clone; returns its sha.</summary>
|
||||
internal static string PushCommitToOrigin(string bare, string tempDir)
|
||||
{
|
||||
var clone = Path.Combine(tempDir, $"clone_{Guid.NewGuid():N}");
|
||||
GitRepoFixture.RunGit(tempDir, "clone", "-q", bare, clone);
|
||||
GitRepoFixture.RunGit(clone, "config", "user.name", "test");
|
||||
GitRepoFixture.RunGit(clone, "config", "user.email", "test@example.com");
|
||||
File.WriteAllText(Path.Combine(clone, "upstream.txt"), "from colleague");
|
||||
GitRepoFixture.RunGit(clone, "add", "-A");
|
||||
GitRepoFixture.RunGit(clone, "commit", "-q", "-m", "upstream work");
|
||||
GitRepoFixture.RunGit(clone, "push", "-q", "origin", "HEAD:main");
|
||||
return GitRepoFixture.RunGit(clone, "rev-parse", "HEAD").Trim();
|
||||
}
|
||||
|
||||
private Task<WorktreeContext> CreateRoot()
|
||||
{
|
||||
using var ctx = _db.CreateContext();
|
||||
var task = ctx.Tasks.Single(t => t.Id == "t1");
|
||||
var list = ctx.Lists.Single();
|
||||
return new WorktreeManager(new GitService(), _db.CreateFactory(), _cfg, NullLogger<WorktreeManager>.Instance)
|
||||
.CreateAsync(task, list, default);
|
||||
}
|
||||
|
||||
private string Head(string rev = "HEAD") => GitRepoFixture.RunGit(_repo.RepoDir, "rev-parse", rev).Trim();
|
||||
|
||||
[Fact]
|
||||
public async Task OriginAhead_RootStartsFromOrigin_AndCheckedOutCleanBaseIsFastForwarded()
|
||||
{
|
||||
var upstream = PushCommitToOrigin(_bare, _tempDir);
|
||||
|
||||
var wt = await CreateRoot();
|
||||
|
||||
Assert.Equal(upstream, wt.BaseCommit);
|
||||
Assert.Equal(upstream, Head());
|
||||
Assert.True(File.Exists(Path.Combine(wt.WorktreePath, "upstream.txt")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DirtyCheckout_StaysUntouched_ButRootStillStartsFromOrigin()
|
||||
{
|
||||
var before = Head();
|
||||
var upstream = PushCommitToOrigin(_bare, _tempDir);
|
||||
var readme = Path.Combine(_repo.RepoDir, "README.md");
|
||||
File.WriteAllText(readme, "local WIP");
|
||||
|
||||
var wt = await CreateRoot();
|
||||
|
||||
Assert.Equal(upstream, wt.BaseCommit);
|
||||
Assert.Equal(before, Head());
|
||||
Assert.Equal("local WIP", File.ReadAllText(readme));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BaseNotCheckedOut_LocalBranchIsFastForwarded_CheckoutUnchanged()
|
||||
{
|
||||
GitRepoFixture.RunGit(_repo.RepoDir, "checkout", "-q", "-b", "other");
|
||||
var upstream = PushCommitToOrigin(_bare, _tempDir);
|
||||
|
||||
var wt = await CreateRoot();
|
||||
|
||||
Assert.Equal(upstream, wt.BaseCommit);
|
||||
Assert.Equal(upstream, Head("main"));
|
||||
Assert.Equal("other", GitRepoFixture.RunGit(_repo.RepoDir, "branch", "--show-current").Trim());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchFailure_DoesNotFailTheTask_RootStartsFromLocalBase()
|
||||
{
|
||||
GitRepoFixture.RunGit(_repo.RepoDir, "remote", "set-url", "origin", Path.Combine(_tempDir, "cdbare_missing.git"));
|
||||
var local = Head("main");
|
||||
|
||||
var wt = await CreateRoot();
|
||||
|
||||
Assert.Equal(local, wt.BaseCommit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PrivateRepo_IsNeverFetched()
|
||||
{
|
||||
_cfg.CompanyRemotePattern = "/_git/";
|
||||
var local = Head("main");
|
||||
PushCommitToOrigin(_bare, _tempDir);
|
||||
|
||||
var wt = await CreateRoot();
|
||||
|
||||
Assert.Equal(local, wt.BaseCommit);
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,7 @@ public sealed class GitRepoFixture : IDisposable
|
||||
RunGit(dir, "commit", "-m", "chore: seed");
|
||||
}
|
||||
|
||||
private static void ForceDeleteDirectory(string path)
|
||||
internal static void ForceDeleteDirectory(string path)
|
||||
{
|
||||
if (!Directory.Exists(path)) return;
|
||||
foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
|
||||
|
||||
@@ -71,6 +71,15 @@ public class CommitMessageBuilderTests
|
||||
Assert.Equal("ClaudeDo-Task: id-456", lines[4]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithoutTrailer_CompanyRepoCommitHasNoClaudeDoTaskLine()
|
||||
{
|
||||
var msg = CommitMessageBuilder.Build("feat", "Lager App", "add scan", "Detailed description here", "id-456",
|
||||
includeTrailer: false);
|
||||
|
||||
Assert.Equal("feat(lager-app): add scan\n\nDetailed description here", msg);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildMerge_ConventionalHeaderWithMergeVerbAndTrailer()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user