Merge branch 'claudedo/387-unit-branch-teil-3-firmen-modus-fetch-pu'

This commit is contained in:
mika kuns
2026-10-01 12:30:13 +02:00
40 changed files with 2857 additions and 47 deletions
+15 -2
View File
@@ -1,8 +1,8 @@
# Review, merge & conflict resolution
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against `44d94caa` (2026-10-01), which made the parent's worktree the unit
> branch (children fast-forwarded into it).
> Last verified against `0e6a48e2` (2026-10-01): parent's worktree is the unit branch (children
> fast-forwarded into it); in a company repo approve ends in a pushed branch + draft PR.
> Planning mode renders per file and `DiffLinesView` is retired.
> Drift check: `git log --oneline 20bce9b..HEAD -- src/ClaudeDo.Worker/Lifecycle src/ClaudeDo.Worker/State src/ClaudeDo.Worker/Planning src/ClaudeDo.Ui/ViewModels/Conflicts src/ClaudeDo.Worker/External`
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
@@ -28,6 +28,19 @@ There is no separate "Merge all" entry.
- **Child of a unit** (parent has an `Active` worktree) → `ApproveAndMergeAsync` absorbs it into
the unit branch (`UnitBranchService.AbsorbChildAsync`, ff-only) instead of merging to the target.
- **No active worktree** (sandbox run) → straight to `Done`.
- **Company repo** (`PullRequestPublisher.IsCompanyRepoAsync`, origin matches
`company_remote_pattern`, checked live) with an `Active` or `Kept` worktree → `ApproveCompanyAsync`
instead of a merge, after the planning-parent gate and the child absorb: missing PAT / non-ADO
remote is `blocked` before anything runs, then the verify command in the **worktree**
(`skipVerify` ignored — no local merge to defer it to; red = `verify_failed`, nothing pushed),
then `PullRequestPublisher.PublishAsync`: `git push -u origin <branch>` (PAT via `GIT_CONFIG_*`
env, `AzureDevOpsClient.GitAuthEnv`), find-or-create the draft PR, store
`worktrees.pull_request_url`, `WorktreeUpdated`. Result `pr_created` / `pr_updated` + URL
(`MergeResult.PullRequestUrl`; MCP puts it in `mergeMessage`). Task stays `WaitingForReview`,
worktree `Active`, ticket `InBearbeitung`; `Done` is the user's manual toggle after the ADO
merge. A PR failure after a successful push is `blocked` with `gepusht, PR fehlgeschlagen: …`.
⚠️ Only approve branches — `merge_task`/the merge modal and the legacy-unit orchestrator still
merge locally in a company repo.
Review transitions all live in `TaskStateService`: `SubmitForReviewAsync`,
`SubmitForChildrenAsync`, `ApproveReviewAsync`, `RejectToQueueAsync`, `RejectToIdleAsync`,
+18
View File
@@ -74,6 +74,24 @@ Bleibt manuell zu prüfen (UI-Pfad, nie sichtbar gelaufen):
- [ ] Base-URL leeren → Menüeintrag verschwindet, keine Netzwerkaufrufe mehr.
- [ ] Sichtprüfung des Settings-Tabs + des Ticket-Projekt-Dropdowns (nie gerendert gesehen).
## Offene Verifikation (2026-10-01, Unit-Branch Teil 3: Firmen-Modus / Draft-PR)
Build + gefilterte Tests grün (Worker: AzureDevOps/CompanyFetchBase/CompanyApprove/CommitMessageBuilder,
Ui: TicketSettingsTab/MergeSectionCompanyRepo, Data: PromptFiles, Localization). **Nie gegen echtes ADO,
nicht visuell geprüft:**
- [ ] Sichtprüfung: Settings-Reiter heißt „Externe Dienste", Abschnitt Azure DevOps (PAT, Test, Löschen);
Detail-Pane im Firmen-Repo: kein Ziel-Dropdown, Button „Draft-PR erstellen" → nach dem Approve
„PR aktualisieren" + Button „Pull Request öffnen"; Approve bleibt hinter „Diff öffnen" gegated.
- [ ] PAT eintragen, „Verbindung testen" — meldet Repo + Default-Branch der ersten Firmen-Liste.
- [ ] Ticket-Task in einem echten Firmen-Repo bis zum Draft-PR: Branch `<typ>/<ticket>-<slug>` startet
von frisch gefetchtem `origin/<basis>`, Commit ohne `ClaudeDo-Task:`-Trailer, PR-Titel
`…, Resolves #<id>`, Beschreibung mit Ticket-Link (Projekt-Id-Lookup über `GET /api/ticket/{id}`
ist nur gegen den Stub getestet) und `Geprüft:`-Zeile, Task bleibt WaitingForReview.
- [ ] Zweiter Approve nach Reject mit Feedback → `pr_updated`, derselbe PR bekommt die neuen Commits.
- [ ] Planning-Unit mit 2 Children im Firmen-Repo → ein PR für die ganze Unit.
- [ ] API-Version-Fallback (Server meldet „supports is X") ist nur gegen den Stub getestet.
## Offene Verifikation (2026-10-01, Ticket-Import über alle Projekte)
Build + Unit-Tests grün, **nicht visuell/live verifiziert**:
@@ -0,0 +1,112 @@
# Unit-Branch Teil 3: Firmen-Modus (Fetch, Push, Draft-PR) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** In a company repo (origin matches `company_remote_pattern`) a unit root starts from a freshly fetched `origin/<basis>`, and Approve ends in a pushed branch plus an Azure DevOps **draft PR** instead of a local merge.
**Architecture:** `AzureDevOpsClient` (port of `pr-create.ps1`) is the only HTTP surface to ADO; it also builds the git auth environment (PAT as `http.extraHeader` via `GIT_CONFIG_COUNT/KEY_0/VALUE_0`, never on the command line). `WorktreeManager.ResolveBaseCommitAsync` fetches the basis for unit roots in company repos. `TaskMergeService.ApproveAndMergeAsync` branches on `CompanyRepo.IsCompanyAsync` into `ApproveCompanyAsync`: verify in the unit-root worktree → `PullRequestPublisher.PublishAsync` (push, find-or-create draft PR, store `worktrees.pull_request_url`, `WorktreeUpdated`). The task stays `WaitingForReview`; result status `pr_created` / `pr_updated`. Every failure is a `blocked` result with a precise reason (hub → `HubException`, MCP → tool error, UI → footer strip).
**Tech Stack:** .NET 8, EF Core SQLite, git CLI, xUnit (real git + SQLite), Avalonia.
Spec: `docs/superpowers/specs/2026-10-01-unit-branch-draft-pr-design.md`, sections 3–5, decision rows "Basis-Branch" / "Basis aktualisieren", section 7.
**Live finding (ticket system, 2026-10-01):** `ParentId` of a ticket is its project (`ItemType 0`, `ParentTicketID` null); a task's parent is the ticket. Project id = walk `GET /api/ticket/{id}` up until `parentTicketID` is null (max 3 hops). Ticket web URL: `https://ticket.fb-tuning.local/board/project/<project>/ticket/<id>`.
## File map
| File | Change |
|---|---|
| `src/ClaudeDo.Data/Models/WorktreeEntity.cs`, `Configuration/WorktreeEntityConfiguration.cs`, `Migrations/*AddWorktreePullRequestUrl*` | `PullRequestUrl` → `worktrees.pull_request_url` |
| `src/ClaudeDo.Data/Repositories/WorktreeRepository.cs` | `SetPullRequestUrlAsync` |
| `src/ClaudeDo.Data/Git/GitService.cs` | env-aware `RunGitAsync`; `FetchBranchAsync`, `PushBranchAsync`, `FastForwardLocalBranchAsync`, `GetRemoteHeadBranchAsync` |
| `src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs`, `WorktreeManager.cs` | trailer off in company repos; fetch basis for unit roots |
| `src/ClaudeDo.Worker/AzureDevOps/AzureDevOpsClient.cs` | **new** — remote parsing, auth env, default branch, find-or-create draft PR, api-version fallback |
| `src/ClaudeDo.Worker/AzureDevOps/PullRequestPublisher.cs` | **new** — push + PR + URL + ticket link |
| `src/ClaudeDo.Worker/DpapiTokenStore.cs`, `Program.cs` | `AzureDevOpsPatStore` (`~/.claudeDo/azdo.pat`), DI |
| `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs` | `ApproveCompanyAsync`, `StatusPrCreated/Updated`, `MergeResult.PullRequestUrl`, `MergeTargets.IsCompanyRepo` |
| `src/ClaudeDo.Worker/Hub/WorkerHub.cs`, `External/ExternalMcpService.cs` | ADO settings methods; PR URL in results |
| `src/ClaudeDo.Data/Wire.cs` | `AzureDevOpsSettingsDto`, `AzureDevOpsConnectionDto`, `MergeResultDto.PullRequestUrl`, `MergeTargetsDto.IsCompanyRepo` |
| `src/ClaudeDo.Ui/...` | IWorkerClient/WorkerClient, settings tab "Externe Dienste", detail-pane PR button/link |
| `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs` | new members |
| `src/ClaudeDo.Data/PromptFiles.cs` | company-repo paragraph in the merge prompt (merge + merge_final share it) |
| `tests/ClaudeDo.Worker.Tests/AzureDevOps/*` | **new** — client, fetch basis, company approve |
| docs | Worker/CLAUDE.md, review-merge.md, open.md |
---
### Task 1: `worktrees.pull_request_url`
- [ ] `WorktreeEntity.PullRequestUrl` (`string?`), mapped to `pull_request_url`.
- [ ] `dotnet ef migrations add AddWorktreePullRequestUrl` (project `src/ClaudeDo.Data`). Check the generated Designer contains `base_branch` (parent `AddListBaseBranch`) — see trap `ef-migration-parallel-designer-collision`.
- [ ] `WorktreeRepository.SetPullRequestUrlAsync(taskId, url, ct)` via `ExecuteUpdateAsync`.
- [ ] Covered by Task 5 tests.
### Task 2: Git primitives + trailer
- [ ] `RunGitAsync(..., IReadOnlyDictionary<string,string>? env = null)`; network ops always set `GIT_TERMINAL_PROMPT=0`.
- [ ] `FetchBranchAsync(repoDir, branch, env, ct)` → `git fetch origin +refs/heads/<b>:refs/remotes/origin/<b>` (explicit refspec: works without a configured fetch refspec); returns `(ExitCode, Stderr)`.
- [ ] `FastForwardLocalBranchAsync(repoDir, branch, ct)` → `git fetch . refs/remotes/origin/<b>:refs/heads/<b>` (local, no network; git refuses a non-fast-forward itself).
- [ ] `PushBranchAsync(worktreePath, branch, env, ct)` → `git push -u origin <b>`.
- [ ] `GetRemoteHeadBranchAsync(repoDir, ct)` → `symbolic-ref --short refs/remotes/origin/HEAD` minus `origin/`, or null.
- [ ] `CommitMessageBuilder.Build(..., bool includeTrailer = true)`; `WorktreeManager.CommitIfChangedAsync` passes `!isCompany`. Test in `CommitMessageBuilderTests`: no `ClaudeDo-Task:` line when false.
### Task 3: `AzureDevOpsClient` + PAT store
- [ ] `AzureDevOpsPatStore(DpapiTokenStore)` marker, registered with `InAppData("azdo.pat")`.
- [ ] `AzureDevOpsClient(IHttpClientFactory, AzureDevOpsPatStore)`:
- `static AzdoRepo? ParseRemote(string remote)` — trim, strip `.git`, strip `user[:pw]@`, split at `/_git/`; `Repo` unescaped.
- `static IReadOnlyDictionary<string,string> GitAuthEnv(string? pat)` — `GIT_TERMINAL_PROMPT=0` + (pat ? `GIT_CONFIG_COUNT=1`, `GIT_CONFIG_KEY_0=http.extraHeader`, `GIT_CONFIG_VALUE_0=Authorization: Basic <b64(":pat")>`).
- `static string? SupportedVersion(string error)` — `supports is (\d+\.\d+)`.
- `GetDefaultBranchAsync(repo, ct)` → `GET …/_apis/git/repositories/<repo>` → `defaultBranch` without `refs/heads/`.
- `EnsureDraftPullRequestAsync(repo, source, target, title, description, ct)` → `GET …/pullrequests?searchCriteria.sourceRefName=refs/heads/<s>&searchCriteria.status=active`; first hit is reused, else `POST …/pullrequests` with `isDraft: true`. Returns `(Url, Created)`, URL `<webUrl>/pullrequest/<id>`.
- api version starts at `6.0`, one retry with the version the error names (remembered).
- errors → `AzureDevOpsException` with status code + server message; no PAT → `AzureDevOpsException("Kein Azure-DevOps-PAT hinterlegt …")`.
- [ ] Tests (`AzureDevOpsClientTests`, `StubHandler` from Tickets): the two `-SelfTest` remotes + non-ADO remote, auth header `Basic OmdlaGVpbQ==`, version parse (match / foreign error), reuse of an active PR (no POST), create with `isDraft` + refs, version fallback retry.
### Task 4: Fetch the basis before a unit root
`WorktreeManager.ResolveBaseCommitAsync`, unit-root branch only, when `CompanyRepo.IsCompanyAsync`:
1. basis = `base_branch` → ADO `defaultBranch` (if the remote parses and a PAT is set) → `GetRemoteHeadBranchAsync`. None → today's behaviour.
2. `FetchBranchAsync(basis, GitAuthEnv(pat))`. Failure → warning, base = local `basis` (else HEAD). No throw.
3. Success → base = `rev-parse origin/<basis>`; then best-effort local update: not checked out → `FastForwardLocalBranchAsync` (only if the local branch exists); checked out + clean (`HasChangesAsync(includeUntracked:false)`) → `MergeFastForwardOnlyAsync(workingDir, origin/<basis>)`; otherwise warning only.
- [ ] Tests (`CompanyFetchBaseTests`, bare repo as origin, pattern = bare path): bare one commit ahead → unit root's `BaseCommit` == bare head, local `main` fast-forwarded; dirty checked-out `main` stays untouched (file content + HEAD unchanged) but the root still starts from origin; unreachable origin → worktree created from local `main`; private repo (pattern mismatch) → no fetch (origin ahead is ignored).
### Task 5: Company approve
- [ ] `TaskMergeService`: `StatusPrCreated = "pr_created"`, `StatusPrUpdated = "pr_updated"`; `MergeResult(..., string? PullRequestUrl = null)`; `MergeTargets(..., bool IsCompanyRepo = false)` filled in `GetTargetsAsync`.
- [ ] `ApproveAndMergeAsync`: after the child-absorb block, if `wt` is Active and the repo is a company repo → `ApproveCompanyAsync` (ignores `skipVerify`):
- verify via `RunVerifyGateAsync(verifyCommand, wt.Path, …)` with `PhaseVerifying` broadcast; red → `verify_failed`, nothing pushed.
- `PullRequestPublisher.PublishAsync(task, list, wt, verified, ct)` wrapped in `ProgressReporter.RunAsync` ("push + draft PR running").
- [ ] `PullRequestPublisher` (`GitService`, `AzureDevOpsClient`, `TicketClientFactory?`, db factory, broadcaster, logger):
- no PAT → blocked; remote does not parse → blocked.
- push with `GitAuthEnv`; rejected → blocked `push rejected: <stderr>`.
- target = `base_branch` else ADO default branch.
- title `<type>(<list-slug>): <title>[, Resolves #<ticket>]`; description `Resolves [#id](<url>)` (link only with a project id) + task description + `Geprüft: <cmd> grün` / `Geprüft: nichts automatisch`.
- PR error after push → blocked `gepusht, PR fehlgeschlagen: …`.
- success → `SetPullRequestUrlAsync`, `WorktreeUpdated`, `pr_created`/`pr_updated` with URL. Task status and worktree state untouched.
- [ ] Hub `ApproveReview`: `MergeResultDto(..., r.PullRequestUrl)`. MCP `review_task`: `mergeMessage` = PR URL for `pr_*`; tool description mentions it.
- [ ] Tests (`CompanyApproveTests`; origin URL `http://ado.test/coll/proj/_git/Repo` + `url.<bare>.pushInsteadOf` so the push lands in a local bare repo while the remote still parses as ADO; HTTP stubbed): creates draft PR (branch in bare, URL stored, task still WaitingForReview, worktree Active, title/description), reuses existing PR → `pr_updated` without POST, verify red → no push, PR error after push → blocked "gepusht, PR fehlgeschlagen", `skipVerify` ignored.
### Task 6: Hub + wire + UI client
- [ ] Wire: `AzureDevOpsSettingsDto(bool TokenSet)`, `AzureDevOpsConnectionDto(bool Ok, string? Repo, string? DefaultBranch, string? Error)`, `MergeResultDto.PullRequestUrl`, `MergeTargetsDto.IsCompanyRepo`.
- [ ] Hub: `GetAzureDevOpsSettings`, `SetAzureDevOpsToken`, `ClearAzureDevOpsToken`, `TestAzureDevOpsConnection` (first list whose origin matches the pattern and parses → `GetDefaultBranchAsync`; every error is an answer, not a throw).
- [ ] `IWorkerClient`/`WorkerClient` + `StubWorkerClient`.
### Task 7: UI
- [ ] Settings: tab header "Externe Dienste"/"External services"; second section Azure DevOps (PAT field, test, clear) in `TicketSettingsTabViewModel` (saved together with the ticket settings).
- [ ] Detail pane: `Merge.IsCompanyRepo` (from targets) and `Merge.PullRequestUrl` (from the worktree row); target dropdown hidden in company repos; approve label "Draft-PR erstellen" / "PR aktualisieren"; PR link button (`ShellOpen.Url`); approve errors in company mode go to the footer strip (`ErrorReported`) instead of a modal; still gated behind "open diff".
- [ ] Locales en + de in parity, both parsed separately.
### Task 8: Merge-helper prompt
- [ ] Company-repo paragraph in `MergeHelperMergeDefault`: approve returns `pr_created`/`pr_updated` + URL, task stays WaitingForReview, no `verify_merges`, summary lists PR links.
### Task 9: Docs
- [ ] `src/ClaudeDo.Worker/CLAUDE.md` (tickets/config section, new Azure DevOps section), `docs/explore-notes/review-merge.md`, `docs/open.md` (manual smoke: PAT + test button, ticket task to draft PR, planning unit with 2 children).
## Verification
`dotnet build ClaudeDo.slnx -c Release`; tests filtered: `FullyQualifiedName~AzureDevOps|FullyQualifiedName~CompanyFetchBase|FullyQualifiedName~CompanyApprove|FullyQualifiedName~CommitMessageBuilder|FullyQualifiedName~UnitBranch|FullyQualifiedName~WorktreeManager` (Worker), Localization.Tests, Ui.Tests filtered to settings/details.
@@ -36,6 +36,7 @@ public class WorktreeEntityConfiguration : IEntityTypeConfiguration<WorktreeEnti
builder.Property(w => w.HeadCommit).HasColumnName("head_commit");
builder.Property(w => w.DiffStat).HasColumnName("diff_stat");
builder.Property(w => w.MergeCommit).HasColumnName("merge_commit");
builder.Property(w => w.PullRequestUrl).HasColumnName("pull_request_url");
builder.Property(w => w.State).HasColumnName("state").IsRequired()
.HasDefaultValue(WorktreeState.Active)
.HasConversion(StateConverter);
+43 -1
View File
@@ -450,6 +450,45 @@ public sealed class GitService
return (exitCode, stderr);
}
/// <summary>
/// <c>git fetch origin</c> for one branch into <c>refs/remotes/origin/&lt;branch&gt;</c>. The explicit
/// refspec works without a configured fetch refspec. <paramref name="env"/> carries the auth
/// header (never the command line) and must disable the terminal prompt.
/// </summary>
public async Task<(int ExitCode, string Stderr)> FetchBranchAsync(
string repoDir, string branch, IReadOnlyDictionary<string, string>? env, CancellationToken ct = default)
{
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
["fetch", "origin", $"+refs/heads/{branch}:refs/remotes/origin/{branch}"], ct, env: env);
return (exitCode, stderr);
}
/// <summary>Moves the local, not checked-out <paramref name="branch"/> to <c>origin/&lt;branch&gt;</c>; git itself refuses a non-fast-forward. No network.</summary>
public async Task<(int ExitCode, string Stderr)> FastForwardLocalBranchAsync(
string repoDir, string branch, CancellationToken ct = default)
{
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
["fetch", ".", $"refs/remotes/origin/{branch}:refs/heads/{branch}"], ct);
return (exitCode, stderr);
}
/// <summary><c>git push -u origin &lt;branch&gt;</c> from <paramref name="worktreePath"/>; auth via <paramref name="env"/>.</summary>
public async Task<(int ExitCode, string Stderr)> PushBranchAsync(
string worktreePath, string branch, IReadOnlyDictionary<string, string>? env, CancellationToken ct = default)
{
var (exitCode, _, stderr) = await RunGitAsync(worktreePath, ["push", "-u", "origin", branch], ct, env: env);
return (exitCode, stderr);
}
/// <summary>Branch that <c>refs/remotes/origin/HEAD</c> points to (without <c>origin/</c>), or null.</summary>
public async Task<string?> GetRemoteHeadBranchAsync(string repoDir, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], ct);
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout)) return null;
var head = stdout.Trim();
return head.StartsWith("origin/", StringComparison.Ordinal) ? head["origin/".Length..] : head;
}
/// <summary><c>git merge --ff-only</c>: moves the checked-out branch to <paramref name="sourceRef"/> or fails without touching it.</summary>
public async Task<(int ExitCode, string Stderr)> MergeFastForwardOnlyAsync(
string worktreePath, string sourceRef, CancellationToken ct = default)
@@ -721,7 +760,8 @@ public sealed class GitService
private static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitAsync(
string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null, bool trimOutput = true)
string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null, bool trimOutput = true,
IReadOnlyDictionary<string, string>? env = null)
{
var psi = new ProcessStartInfo
{
@@ -742,6 +782,8 @@ public sealed class GitService
// of the OS locale git for Windows picks up.
psi.Environment["LC_ALL"] = "C";
psi.Environment["LANGUAGE"] = "C";
if (env is not null)
foreach (var (key, value) in env) psi.Environment[key] = value;
psi.ArgumentList.Add("-C");
psi.ArgumentList.Add(workDir);
foreach (var a in args) psi.ArgumentList.Add(a);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddWorktreePullRequestUrl : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "pull_request_url",
table: "worktrees",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "pull_request_url",
table: "worktrees");
}
}
}
@@ -1019,6 +1019,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("PullRequestUrl")
.HasColumnType("TEXT")
.HasColumnName("pull_request_url");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
@@ -17,6 +17,8 @@ public sealed class WorktreeEntity
public string? HeadCommit { get; set; }
public string? DiffStat { get; set; }
public string? MergeCommit { get; set; }
/// <summary>Draft-PR auf Azure DevOps, gesetzt vom Approve im Firmen-Repo.</summary>
public string? PullRequestUrl { get; set; }
public WorktreeState State { get; set; } = WorktreeState.Active;
public required DateTime CreatedAt { get; init; }
+5 -1
View File
@@ -552,6 +552,10 @@ public static class PromptFiles
- Prefer the MCP tools whenever they apply. Only if the MCP tools cannot reach an in-progress merge may you finish it by hand: resolve the markers, then `git add -- <the resolved paths>` and `git commit` — NEVER `git add -A` or a bare commit, because the checkout is shared with other sessions.
- Never use raw `git merge`, `git reset`, or `git checkout` to force a merge. Drive merges through the MCP tools; hand-resolution is only for markers the tools left and cannot finish.
## Company repos (Azure DevOps)
If the list's repo is a company repo (origin on the Azure DevOps Server), approve does not merge anything locally: review_task approve runs the verify command in the task's own worktree, pushes its branch and opens a draft PR (or reuses the one already open for that branch). mergeStatus is "pr_created" or "pr_updated" and mergeMessage is the PR URL; the task deliberately stays WaitingForReview until the user merges the PR on Azure DevOps and ticks it Done. There are no merge conflicts to resolve and main does not move, so skip preview_merge_set's merge-order reasoning, do NOT call verify_merges (the verify already ran inside approve, skipVerify is ignored there), and treat "erst mergen, dann neu starten" as harmless. A blocked result names what failed (no Azure DevOps PAT, push rejected, or "gepusht, PR fehlgeschlagen: …" when the push landed but the PR call did not) — report it, do not retry blindly. In the Phase 5 summary, list every PR link.
## Verify once — after the last merge
When every merge above is done (including conflict resolutions), call verify_merges with the ids of ALL tasks that reported merged_verify_pending. It runs the list's verify command a single time and promotes them all to Done on success — that is the per-merge gate you skipped, paid once.
@@ -600,7 +604,7 @@ public static class PromptFiles
## Phase 5 — Summary
Print one line per task from the brief:
title — final status — merge commit (if any) — conflicts resolved (if any) — verify-failed (yes/no).
title — final status — merge commit or PR link (if any) — conflicts resolved (if any) — verify-failed (yes/no).
Then list anything you skipped or left for the user and why, and any follow-ups worth turning into new tasks.
""";
@@ -45,6 +45,14 @@ public sealed class WorktreeRepository
.SetProperty(w => w.MergeCommit, mergeCommit), ct);
}
/// <summary>Stores the draft PR an approve in a company repo created or reused.</summary>
public async Task SetPullRequestUrlAsync(string taskId, string url, CancellationToken ct = default)
{
await _context.Worktrees
.Where(w => w.TaskId == taskId)
.ExecuteUpdateAsync(s => s.SetProperty(w => w.PullRequestUrl, url), ct);
}
/// <summary>Records a successful auto-rebase: the branch's fork point moves to the rebased-onto
/// commit and its head moves to the new tip <c>git rebase</c> produced.</summary>
public async Task SetRebasedAsync(string taskId, string baseCommit, string headCommit, CancellationToken ct = default)
+5 -2
View File
@@ -96,7 +96,7 @@ public record PlanningMergeConflictStateDto(string PlanningTaskId, string Subtas
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);
@@ -108,7 +108,7 @@ public record MergePreviewDto(
public record MergeTargetsDto(
string DefaultBranch, IReadOnlyList<string> LocalBranches, string DefaultCommitMessage,
bool TargetDirty = false);
bool TargetDirty = false, bool IsCompanyRepo = false);
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
@@ -240,5 +240,8 @@ public record HandlerOpenTaskDto(string Id, string Title);
public record TicketSettingsDto(string? ApiBaseUrl, bool TokenSet, string? FallbackListId = null);
public record TicketConnectionDto(bool Ok, string? UserName, IReadOnlyList<string> Scopes, string? Error);
public record AzureDevOpsSettingsDto(bool TokenSet);
// Repo = the company repo the test ran against (first list whose origin matches), DefaultBranch = its ADO default.
public record AzureDevOpsConnectionDto(bool Ok, string? Repo, string? DefaultBranch, string? Error);
public record TicketProjectDto(int Id, string Title, string DepartmentName);
public record TicketImportResultDto(int Examined, int Created);
+12 -3
View File
@@ -16,7 +16,7 @@
"categoryFiles": "Dateien & Prompts",
"categorySkills": "Session Skills",
"categoryReports": "Berichte",
"categoryTickets": "Ticketsystem"
"categoryTickets": "Externe Dienste"
},
"repoHint": {
"text": "Wirkt erst mit verknüpftem Repo.",
@@ -148,6 +148,10 @@
"saveButton": "Konfiguration speichern"
},
"tickets": {
"ticketSection": "TICKETSYSTEM",
"azdoSection": "AZURE DEVOPS",
"azdoToken": "Personal Access Token (Scope: Code Read & Write)",
"azdoHint": "Gilt in Firmen-Repos (origin auf dem Azure DevOps Server): Fetch vor dem Task-Start, Push und Draft-PR beim Genehmigen.",
"baseUrl": "API-Base-URL",
"baseUrlHint": "Leer lassen, um die Ticket-Anbindung komplett zu deaktivieren.",
"token": "Personal Access Token",
@@ -335,6 +339,10 @@
"openDiffHint": "Diff öffnen, um Mergen freizuschalten",
"targetDirty": "Ziel-Arbeitsverzeichnis hat uncommittete Änderungen — beim Mergen werden sie gestasht und danach wiederhergestellt.",
"approveMerge": "Genehmigen & Mergen",
"createDraftPr": "Draft-PR erstellen",
"updatePr": "PR aktualisieren",
"openPr": "Pull Request öffnen",
"companyTargetHint": "Firmen-Repo — Genehmigen pusht den Branch und erstellt einen Draft-PR auf Azure DevOps.",
"park": "Parken",
"parkTip": "Zurückstellen — zurück auf Leerlauf, Worktree bleibt erhalten",
"cancel": "Abbrechen",
@@ -892,7 +900,7 @@
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Wird zusammengeführt…", "progressVerifying": "Verify-Kommando der Liste läuft… ({0})", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "dirtyTarget": "Merge abgelehnt — das Ziel-Arbeitsverzeichnis hat uncommittete Änderungen.", "targetDirty": "Das Ziel-Arbeitsverzeichnis hat bereits uncommittete Änderungen — stashen und mergen?", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
"settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" },
"onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signedInNoRole": "Angemeldet, aber diesem Konto fehlt die Rolle 'user' in Zitadel — die Online-Synchronisierung wird abgelehnt, bis die Rolle im ClaudeDo-Projekt zugewiesen wird.", "signInFailed": "Anmeldung fehlgeschlagen: {0}", "signedOut": "Abgemeldet.", "signOutFailed": "Abmeldung fehlgeschlagen: {0}" },
"tickets": { "connectionOk": "Verbunden als {0}.", "connectionMissingScopes": "Verbunden als {0}, aber es fehlen Scopes: {1}", "tokenCleared": "Token entfernt.", "workerOffline": "Worker nicht erreichbar." },
"tickets": { "azdoConnectionOk": "Verbunden — Repo {0}, Default-Branch {1}.", "connectionOk": "Verbunden als {0}.", "connectionMissingScopes": "Verbunden als {0}, aber es fehlen Scopes: {1}", "tokenCleared": "Token entfernt.", "workerOffline": "Worker nicht erreichbar." },
"weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "error": "Fehler: {0}" },
"usageMonitor": {
"loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}",
@@ -919,7 +927,8 @@
"stalledHint": "Läuft weiter — Worker antwortet noch",
"merge": {
"merging": "Wird zusammengeführt…",
"verifying": "Verify-Kommando der Liste läuft… ({0})"
"verifying": "Verify-Kommando der Liste läuft… ({0})",
"publishing": "Push und Draft-PR laufen…"
},
"diff": {
"parsing": "Diff wird geparst…"
+12 -3
View File
@@ -16,7 +16,7 @@
"categoryFiles": "Files & Prompts",
"categorySkills": "Session Skills",
"categoryReports": "Reports",
"categoryTickets": "Ticket system"
"categoryTickets": "External services"
},
"repoHint": {
"text": "Only takes effect once a repo is linked.",
@@ -148,6 +148,10 @@
"saveButton": "Save config"
},
"tickets": {
"ticketSection": "TICKET SYSTEM",
"azdoSection": "AZURE DEVOPS",
"azdoToken": "Personal access token (scope: Code read & write)",
"azdoHint": "Used in company repos (origin on the Azure DevOps Server): fetch before a task starts, push and draft PR on approve.",
"baseUrl": "API base URL",
"baseUrlHint": "Leave empty to disable the ticket integration entirely.",
"token": "Personal access token",
@@ -335,6 +339,10 @@
"openDiffHint": "Open the diff to enable merge",
"targetDirty": "Target working directory has uncommitted changes — merging will stash and restore them.",
"approveMerge": "Approve & Merge",
"createDraftPr": "Create draft PR",
"updatePr": "Update PR",
"openPr": "Open pull request",
"companyTargetHint": "Company repo — approve pushes the branch and opens a draft PR on Azure DevOps.",
"park": "Park",
"parkTip": "Set aside — back to Idle, keeps the worktree",
"cancel": "Cancel",
@@ -892,7 +900,7 @@
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Merging…", "progressVerifying": "Running the list's verify command… ({0})", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "dirtyTarget": "Merge refused — the target working directory has uncommitted changes.", "targetDirty": "The target working directory already has uncommitted changes — stash them and merge?", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
"settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" },
"onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signedInNoRole": "Signed in, but this account is missing the 'user' role in Zitadel — online sync will be rejected until the role is granted in the ClaudeDo project.", "signInFailed": "Sign-in failed: {0}", "signedOut": "Signed out.", "signOutFailed": "Sign-out failed: {0}" },
"tickets": { "connectionOk": "Connected as {0}.", "connectionMissingScopes": "Connected as {0}, but missing scopes: {1}", "tokenCleared": "Token removed.", "workerOffline": "Worker not reachable." },
"tickets": { "azdoConnectionOk": "Connected — repo {0}, default branch {1}.", "connectionOk": "Connected as {0}.", "connectionMissingScopes": "Connected as {0}, but missing scopes: {1}", "tokenCleared": "Token removed.", "workerOffline": "Worker not reachable." },
"weeklyReport": { "invalidRange": "Invalid date range.", "error": "Error: {0}" },
"usageMonitor": {
"loadFailed": "Couldn't load usage data: {0}",
@@ -919,7 +927,8 @@
"stalledHint": "Still running — worker hasn't responded in a while",
"merge": {
"merging": "Merging…",
"verifying": "Running the list's verify command… ({0})"
"verifying": "Running the list's verify command… ({0})",
"publishing": "Pushing and opening the draft PR…"
},
"diff": {
"parsing": "Parsing diff…"
@@ -260,6 +260,10 @@ public interface IWorkerClient : INotifyPropertyChanged
Task SetTicketTokenAsync(string token);
Task ClearTicketTokenAsync();
Task<TicketConnectionDto?> TestTicketConnectionAsync();
Task<AzureDevOpsSettingsDto?> GetAzureDevOpsSettingsAsync();
Task SetAzureDevOpsTokenAsync(string token);
Task ClearAzureDevOpsTokenAsync();
Task<AzureDevOpsConnectionDto?> TestAzureDevOpsConnectionAsync();
Task<List<TicketProjectDto>> GetTicketProjectsAsync();
/// <summary>Imports open tickets assigned to the token owner into the given list. Throws
/// HubException (with a user-readable message) on failure so the caller can surface it via
+12
View File
@@ -791,6 +791,18 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public Task<TicketConnectionDto?> TestTicketConnectionAsync()
=> TryInvokeAsync<TicketConnectionDto>("TestTicketConnection");
public Task<AzureDevOpsSettingsDto?> GetAzureDevOpsSettingsAsync()
=> TryInvokeAsync<AzureDevOpsSettingsDto>("GetAzureDevOpsSettings");
public Task SetAzureDevOpsTokenAsync(string token)
=> InvokeTimedAsync("SetAzureDevOpsToken", () => _hub.InvokeAsync("SetAzureDevOpsToken", token));
public Task ClearAzureDevOpsTokenAsync()
=> InvokeTimedAsync("ClearAzureDevOpsToken", () => _hub.InvokeAsync("ClearAzureDevOpsToken"));
public Task<AzureDevOpsConnectionDto?> TestAzureDevOpsConnectionAsync()
=> TryInvokeAsync<AzureDevOpsConnectionDto>("TestAzureDevOpsConnection");
public async Task<List<TicketProjectDto>> GetTicketProjectsAsync()
=> await TryInvokeAsync<List<TicketProjectDto>>("GetTicketProjects") ?? [];
@@ -676,6 +676,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
Merge.SyncTaskContext(row.Id, row.Title, row.IsPlanningParent);
Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
WorktreeStateLabel, _listWorkingDir);
Merge.PullRequestUrl = entity.Worktree?.PullRequestUrl;
await AgentSettings.LoadForTaskAsync(entity, ct);
ct.ThrowIfCancellationRequested();
@@ -728,6 +729,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
foreach (var b in targets.LocalBranches) Merge.MergeTargetBranches.Add(b);
Merge.SelectedMergeTarget = targets.DefaultBranch;
Merge.TargetDirty = targets.TargetDirty;
Merge.IsCompanyRepo = targets.IsCompanyRepo;
}
}
await Merge.RefreshMergePreviewAsync();
@@ -779,6 +781,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
Merge.MergeTargetBranches.Add(b);
Merge.SelectedMergeTarget = targets.DefaultBranch;
Merge.TargetDirty = targets.TargetDirty;
Merge.IsCompanyRepo = targets.IsCompanyRepo;
}
}
}
@@ -826,6 +829,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
Merge.MergeTargetBranches.Add(b);
Merge.SelectedMergeTarget = targets.DefaultBranch;
Merge.TargetDirty = targets.TargetDirty;
Merge.IsCompanyRepo = targets.IsCompanyRepo;
}
}
}
@@ -939,6 +943,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
WorktreeStateLabel, _listWorkingDir);
Merge.PullRequestUrl = entity.Worktree?.PullRequestUrl;
}
catch { /* best-effort refresh */ }
}
@@ -1261,7 +1266,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
{
if (Task is null || !_worker.IsConnected) return;
var taskId = Task.Id;
using var op = ApproveOp.Begin(Loc.T("ops.merge.merging"));
// Company repo: approve = verify + push + draft PR; nothing merges locally.
var company = Merge.IsCompanyRepo;
using var op = ApproveOp.Begin(Loc.T(company ? "ops.merge.publishing" : "ops.merge.merging"));
// Subscribed only for the duration of the call: the worker broadcasts to every
// client, and a VM left on this event would keep reacting to merges for other tasks.
void OnOperationProgress(string opKey, string phase, int current, int total)
@@ -1276,7 +1283,14 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
try
{
var hasChildren = Subtasks.Count > 0 || ChildOutcomes.Count > 0;
var result = await _worker.ApproveReviewAsync(taskId, Merge.SelectedMergeTarget ?? "");
var result = await _worker.ApproveReviewAsync(taskId, company ? "" : Merge.SelectedMergeTarget ?? "");
if (company)
{
if (result?.PullRequestUrl is { } url) Merge.PullRequestUrl = url;
else if (result?.Status == "verify_failed") ErrorReported?.Invoke(result.ErrorMessage ?? Loc.T("vm.detailsIsland.verifyFailed"));
return;
}
// Nothing landed -- the target working dir has uncommitted changes (often another
// session's WIP in the shared main checkout). Offer the one-shot stash retry; git
@@ -1304,7 +1318,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
}
catch (Exception ex)
{
if (Dialogs != null)
// Company-repo failures (no PAT, push rejected, "gepusht, PR fehlgeschlagen") go to the footer strip.
if (company) ErrorReported?.Invoke(ex.Message);
else if (Dialogs != null)
await Dialogs.ShowErrorAsync(ex.Message);
}
finally
@@ -32,6 +32,22 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
// dir is visible before anyone clicks, not just after the merge modal refuses it.
[ObservableProperty] private bool _targetDirty;
/// <summary>Mirrors MergeTargetsDto.IsCompanyRepo: approve pushes + opens a draft PR, there is no target to pick.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowApproveMerge), nameof(ShowCreatePr), nameof(ShowUpdatePr))]
private bool _isCompanyRepo;
/// <summary>The worktree's draft PR (company repo), once an approve created it.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowCreatePr), nameof(ShowUpdatePr), nameof(HasPullRequest))]
[NotifyCanExecuteChangedFor(nameof(OpenPullRequestCommand))]
private string? _pullRequestUrl;
public bool ShowApproveMerge => !IsCompanyRepo;
public bool ShowCreatePr => IsCompanyRepo && PullRequestUrl is null;
public bool ShowUpdatePr => IsCompanyRepo && PullRequestUrl is not null;
public bool HasPullRequest => PullRequestUrl is not null;
// No CanExecute to gate here — RefreshMergePreviewAsync is triggered from the Bind path,
// not a [RelayCommand], so PreviewOp only drives the indicator, never locks a button.
public OperationStatus PreviewOp { get; } = new();
@@ -146,11 +162,20 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
MergeIsClean = false;
MergeIsConflict = false;
TargetDirty = false;
IsCompanyRepo = false;
PullRequestUrl = null;
SyncWorktree(null, null, null, null, null);
SyncTaskContext(null, null, false);
SyncChildOutcomes(false, 0);
}
[RelayCommand(CanExecute = nameof(HasPullRequest))]
private void OpenPullRequest()
{
var (ok, error) = ShellOpen.Url(PullRequestUrl);
if (!ok && error is not null) ErrorReported?.Invoke(error);
}
[RelayCommand(CanExecute = nameof(CanReviewDiff))]
private async System.Threading.Tasks.Task ReviewCombinedDiffAsync()
{
@@ -40,6 +40,15 @@ public sealed partial class TicketSettingsTabViewModel : ViewModelBase
// Never shows the stored token — TicketSettingsDto only ever carries the bool.
public string TokenPlaceholder => TokenSet ? Loc.T("settings.tickets.tokenSet") : "";
/// <summary>Azure DevOps PAT being typed; never shows the stored one.</summary>
[ObservableProperty] private string _azdoToken = "";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(AzdoTokenPlaceholder))]
private bool _azdoTokenSet;
[ObservableProperty] private string _azdoStatusMessage = "";
public string AzdoTokenPlaceholder => AzdoTokenSet ? Loc.T("settings.tickets.tokenSet") : "";
public ObservableCollection<FallbackListOption> FallbackLists { get; } = new();
[ObservableProperty] private FallbackListOption? _selectedFallbackList;
@@ -54,6 +63,7 @@ public sealed partial class TicketSettingsTabViewModel : ViewModelBase
public async Task LoadAsync()
{
AzdoTokenSet = (await _worker.GetAzureDevOpsSettingsAsync())?.TokenSet ?? false;
var dto = await _worker.GetTicketSettingsAsync();
if (dto is null)
{
@@ -86,6 +96,40 @@ public sealed partial class TicketSettingsTabViewModel : ViewModelBase
}
if (_fallbackLoaded)
await _worker.SetTicketFallbackListAsync(SelectedFallbackList?.Id);
if (!string.IsNullOrWhiteSpace(AzdoToken))
{
await _worker.SetAzureDevOpsTokenAsync(AzdoToken);
AzdoToken = "";
AzdoTokenSet = true;
}
}
[RelayCommand]
private async Task TestAzdoConnection()
{
IsBusy = true;
AzdoStatusMessage = "";
try
{
await SaveAsync();
var result = await _worker.TestAzureDevOpsConnectionAsync();
AzdoStatusMessage = result is { Ok: true }
? Loc.T("vm.tickets.azdoConnectionOk", result.Repo!, result.DefaultBranch!)
: result?.Error ?? Loc.T("vm.tickets.workerOffline");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task ClearAzdoToken()
{
await _worker.ClearAzureDevOpsTokenAsync();
AzdoToken = "";
AzdoTokenSet = false;
AzdoStatusMessage = Loc.T("vm.tickets.tokenCleared");
}
[RelayCommand]
@@ -321,8 +321,18 @@
<TextBlock Classes="diff-del" Text="{Binding DiffDelText}" />
</StackPanel>
<!-- Company repo: no local target — approve pushes and opens a draft PR. -->
<StackPanel Spacing="6" IsVisible="{Binding Merge.IsCompanyRepo}">
<TextBlock Classes="meta" Text="{loc:Tr details.workConsole.companyTargetHint}" TextWrapping="Wrap" />
<Button Classes="btn" HorizontalAlignment="Left"
Content="{loc:Tr details.workConsole.openPr}"
ToolTip.Tip="{Binding Merge.PullRequestUrl}"
Command="{Binding Merge.OpenPullRequestCommand}"
IsVisible="{Binding Merge.HasPullRequest}" />
</StackPanel>
<!-- Target branch + pre-flight mergeability -->
<StackPanel Spacing="4">
<StackPanel Spacing="4" IsVisible="{Binding Merge.ShowApproveMerge}">
<TextBlock Classes="field-label" Text="{loc:Tr details.workConsole.targetBranch}" />
<ComboBox ItemsSource="{Binding Merge.MergeTargetBranches}"
SelectedItem="{Binding Merge.SelectedMergeTarget, Mode=TwoWay}"
@@ -333,7 +343,8 @@
IsVisible="{Binding Merge.TargetDirty}" />
</StackPanel>
<StackPanel Spacing="0">
<!-- The preview is against the local target, meaningless when the PR targets origin. -->
<StackPanel Spacing="0" IsVisible="{Binding Merge.ShowApproveMerge}">
<TextBlock Classes="meta" Text="{Binding Merge.MergePreviewText}" TextWrapping="Wrap"
Foreground="{DynamicResource MossBrush}"
IsVisible="{Binding Merge.MergeIsClean}" />
@@ -400,8 +411,13 @@
<WrapPanel Orientation="Horizontal">
<StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}" Margin="0,0,8,8">
<Button Classes="btn accent" Content="{loc:Tr details.workConsole.approveMerge}"
Command="{Binding ApproveReviewCommand}" />
<Button Classes="btn accent" Command="{Binding ApproveReviewCommand}">
<Panel>
<TextBlock Text="{loc:Tr details.workConsole.approveMerge}" IsVisible="{Binding Merge.ShowApproveMerge}" />
<TextBlock Text="{loc:Tr details.workConsole.createDraftPr}" IsVisible="{Binding Merge.ShowCreatePr}" />
<TextBlock Text="{loc:Tr details.workConsole.updatePr}" IsVisible="{Binding Merge.ShowUpdatePr}" />
</Panel>
</Button>
<controls:OperationIndicator Status="{Binding ApproveOp}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}" Margin="0,0,8,8">
@@ -738,12 +738,14 @@
</ScrollViewer>
</TabItem>
<!-- Ticketsystem: Base-URL + PAT for the Bandel ticket integration. Empty base URL
disables the feature entirely, so this tab intentionally never forces a value. -->
<!-- External services: Bandel ticket system (base URL + PAT; empty base URL disables it, so
this tab never forces a value) and the Azure DevOps PAT for company-repo push + draft PR. -->
<TabItem Header="{loc:Tr settings.sidebar.categoryTickets}">
<ScrollViewer>
<StackPanel Spacing="14" Margin="0,8,0,0">
<TextBlock Classes="section-label" Text="{loc:Tr settings.tickets.ticketSection}"/>
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.tickets.baseUrl}"/>
<TextBox Text="{Binding Tickets.ApiBaseUrl, Mode=TwoWay}"/>
@@ -785,6 +787,33 @@
<TextBlock Classes="meta" Text="{loc:Tr settings.tickets.fallbackListHint}"
TextWrapping="Wrap" Opacity="0.6"/>
</StackPanel>
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0" Margin="0,8,0,0"/>
<TextBlock Classes="section-label" Text="{loc:Tr settings.tickets.azdoSection}"/>
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.tickets.azdoToken}"/>
<TextBox Text="{Binding Tickets.AzdoToken, Mode=TwoWay}"
PasswordChar="•"
PlaceholderText="{Binding Tickets.AzdoTokenPlaceholder}"/>
<TextBlock Classes="meta" Text="{loc:Tr settings.tickets.azdoHint}"
TextWrapping="Wrap" Opacity="0.6"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="btn"
Content="{loc:Tr settings.tickets.testConnection}"
Command="{Binding Tickets.TestAzdoConnectionCommand}"
IsEnabled="{Binding !Tickets.IsBusy}"/>
<Button Classes="btn danger"
Content="{loc:Tr settings.tickets.clearToken}"
Command="{Binding Tickets.ClearAzdoTokenCommand}"
IsEnabled="{Binding !Tickets.IsBusy}"/>
</StackPanel>
<TextBlock Classes="meta" Text="{Binding Tickets.AzdoStatusMessage}" TextWrapping="Wrap"
IsVisible="{Binding Tickets.AzdoStatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</ScrollViewer>
</TabItem>
@@ -0,0 +1,187 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace ClaudeDo.Worker.AzureDevOps;
/// <summary>An Azure DevOps Server repo derived from its remote URL; <see cref="ApiBase"/> is collection + project.</summary>
public sealed record AzdoRepo(string ApiBase, string Repo, string WebUrl);
/// <summary>A REST or configuration failure, with a message that can go straight to the user.</summary>
public sealed class AzureDevOpsException : Exception
{
public AzureDevOpsException(string message) : base(message) { }
}
/// <summary>
/// The one HTTP surface to the on-prem Azure DevOps Server, ported from work-conventions' <c>pr-create.ps1</c>.
/// REST, not the az CLI (cloud-only) and not the pullrequestcreate URL (on-prem ignores title/description).
/// </summary>
public sealed class AzureDevOpsClient
{
private const string DefaultApiVersion = "6.0";
// ADO caps a PR description at 4000 characters and rejects anything longer.
private const int MaxDescriptionLength = 4000;
private readonly IHttpClientFactory _httpFactory;
private readonly AzureDevOpsPatStore _pat;
private string _apiVersion = DefaultApiVersion;
public AzureDevOpsClient(IHttpClientFactory httpFactory, AzureDevOpsPatStore pat)
{
_httpFactory = httpFactory;
_pat = pat;
}
/// <summary>The stored PAT, or null.</summary>
public string? Pat => _pat.Store.Read();
/// <summary>
/// Splits an ADO remote at <c>/_git/</c>: everything before it already is the API prefix, so collection
/// and project never need parsing (holds for virtual directories like <c>/tfs/</c> too). Embedded
/// credentials are dropped. Null for anything that is not an http(s) ADO remote.
/// </summary>
public static AzdoRepo? ParseRemote(string? remote)
{
if (string.IsNullOrWhiteSpace(remote)) return null;
var clean = Regex.Replace(remote.Trim(), @"\.git$", "");
clean = Regex.Replace(clean, @"^(https?://)[^/@]+@", "$1");
var m = Regex.Match(clean, @"^(?<base>https?://.+)/_git/(?<repo>[^/]+)$");
return m.Success ? new AzdoRepo(m.Groups["base"].Value, Uri.UnescapeDataString(m.Groups["repo"].Value), clean) : null;
}
/// <summary>Basic auth with an empty user, as ADO expects for a PAT.</summary>
public static string AuthHeaderValue(string pat) => "Basic " + Convert.ToBase64String(Encoding.ASCII.GetBytes(":" + pat));
/// <summary>
/// Environment for a git network call: never prompt — neither the terminal nor Git Credential Manager's GUI
/// (a background process would hang on either) — and hand the PAT
/// over as <c>http.extraHeader</c> via <c>GIT_CONFIG_*</c> so it never shows up in a command line.
/// </summary>
public static IReadOnlyDictionary<string, string> GitAuthEnv(string? pat)
{
var env = new Dictionary<string, string> { ["GIT_TERMINAL_PROMPT"] = "0", ["GCM_INTERACTIVE"] = "never" };
if (!string.IsNullOrEmpty(pat))
{
env["GIT_CONFIG_COUNT"] = "1";
env["GIT_CONFIG_KEY_0"] = "http.extraHeader";
env["GIT_CONFIG_VALUE_0"] = "Authorization: " + AuthHeaderValue(pat);
}
return env;
}
/// <summary>The API version an out-of-range error names (<c>… supports is 6.0</c>), else null.</summary>
public static string? SupportedVersion(string? error)
{
if (error is null) return null;
var m = Regex.Match(error, @"supports is (?<v>\d+\.\d+)");
return m.Success ? m.Groups["v"].Value : null;
}
/// <summary>The repo's default branch without <c>refs/heads/</c>.</summary>
public async Task<string> GetDefaultBranchAsync(AzdoRepo repo, CancellationToken ct)
{
var json = await SendAsync(repo, HttpMethod.Get, "", null, null, ct);
var branch = json["defaultBranch"]?.GetValue<string>()
?? throw new AzureDevOpsException($"Azure DevOps repo '{repo.Repo}' has no default branch.");
return StripHeads(branch);
}
/// <summary>
/// Reuses the active PR for <paramref name="source"/> if there is one (idempotent — the work-conventions
/// skill may already have opened it), else opens a draft PR into <paramref name="target"/>.
/// </summary>
public async Task<(string Url, bool Created)> EnsureDraftPullRequestAsync(
AzdoRepo repo, string source, string target, string title, string description, CancellationToken ct)
{
var sourceRef = "refs/heads/" + source;
var existing = await SendAsync(repo, HttpMethod.Get, "pullrequests",
$"searchCriteria.sourceRefName={Uri.EscapeDataString(sourceRef)}&searchCriteria.status=active&", null, ct);
if (existing["value"] is JsonArray { Count: > 0 } prs && prs[0]?["pullRequestId"]?.GetValue<int>() is { } existingId)
return (PullRequestUrl(repo, existingId), false);
var body = new JsonObject
{
["sourceRefName"] = sourceRef,
["targetRefName"] = "refs/heads/" + target,
["title"] = title,
["description"] = description.Length <= MaxDescriptionLength ? description : description[..MaxDescriptionLength],
["isDraft"] = true,
};
var created = await SendAsync(repo, HttpMethod.Post, "pullrequests", null, body.ToJsonString(), ct);
var id = created["pullRequestId"]?.GetValue<int>()
?? throw new AzureDevOpsException("Azure DevOps answered the PR request without a pullRequestId.");
return (PullRequestUrl(repo, id), true);
}
private static string PullRequestUrl(AzdoRepo repo, int id) => $"{repo.WebUrl}/pullrequest/{id}";
private static string StripHeads(string branch) =>
branch.StartsWith("refs/heads/", StringComparison.Ordinal) ? branch["refs/heads/".Length..] : branch;
private async Task<JsonObject> SendAsync(
AzdoRepo repo, HttpMethod method, string path, string? query, string? body, CancellationToken ct)
{
var pat = Pat;
if (string.IsNullOrEmpty(pat))
throw new AzureDevOpsException("Kein Azure-DevOps-PAT hinterlegt (Einstellungen → Externe Dienste).");
var baseUrl = $"{repo.ApiBase}/_apis/git/repositories/{Uri.EscapeDataString(repo.Repo)}";
if (path.Length > 0) baseUrl += "/" + path;
// The server names the version it supports in its error; retry once with it instead of making the user configure one.
for (var attempt = 1; ; attempt++)
{
using var request = new HttpRequestMessage(method, $"{baseUrl}?{query}api-version={_apiVersion}");
request.Headers.Authorization = AuthenticationHeaderValue.Parse(AuthHeaderValue(pat));
if (body is not null) request.Content = new StringContent(body, Encoding.UTF8, "application/json");
HttpResponseMessage response;
try { response = await _httpFactory.CreateClient("azdo").SendAsync(request, ct); }
catch (Exception ex) when (!ct.IsCancellationRequested)
{
throw new AzureDevOpsException($"Azure DevOps nicht erreichbar ({repo.ApiBase}): {ex.Message}");
}
using (response)
{
var payload = await response.Content.ReadAsStringAsync(ct);
// A rejected PAT comes back as 203 + an HTML login page, which IsSuccessStatusCode counts as success.
if ((int)response.StatusCode == 203)
throw new AzureDevOpsException("Azure DevOps 203: PAT abgelaufen oder ohne Scope Code (Read & Write).");
if (response.IsSuccessStatusCode)
{
try
{
return JsonNode.Parse(payload) as JsonObject
?? throw new AzureDevOpsException($"Unerwartete Antwort von Azure DevOps bei {method} {path}.");
}
catch (JsonException ex) { throw new AzureDevOpsException($"Unerwartete Antwort von Azure DevOps: {ex.Message}"); }
}
var detail = ErrorMessage(payload);
var better = SupportedVersion(detail);
if (attempt == 1 && better is not null && better != _apiVersion)
{
_apiVersion = better;
continue;
}
var hint = (int)response.StatusCode == 401 ?" — PAT abgelaufen oder ohne Scope Code (Read & Write)" : "";
throw new AzureDevOpsException($"Azure DevOps {(int)response.StatusCode} bei {method} {path}{hint}: {detail}");
}
}
}
private static string ErrorMessage(string payload)
{
try
{
if (JsonNode.Parse(payload) is JsonObject o && o["message"]?.GetValue<string>() is { Length: > 0 } msg) return msg;
}
// InvalidOperationException: "message" exists but is not a string.
catch (Exception ex) when (ex is JsonException or InvalidOperationException) { }
return payload.Length <= 500 ? payload : payload[..500];
}
}
@@ -0,0 +1,165 @@
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);
}
// InvalidOperationException: a field of the ADO answer has an unexpected JSON type.
catch (Exception ex) when (ex is AzureDevOpsException or InvalidOperationException)
{
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>&lt;type&gt;(&lt;list-slug&gt;): &lt;title&gt;, Resolves #&lt;ticket&gt;</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);
}
+39 -4
View File
@@ -36,6 +36,8 @@ Worker/
Prime/ — "Prime Claude" schedules: PrimeScheduler, PrimeRunner, PrimePrompts,
DailyPrepPrompt, PrimeScheduleValidation, NextDueCalculator, PrimeScheduleSignal
Online/ — optional Online Inbox sync (off by default; zero network when disabled)
AzureDevOps/ — company-repo push + draft PR: AzureDevOpsClient (REST, port of pr-create.ps1),
PullRequestPublisher (push, find-or-create draft PR, pull_request_url)
Tickets/ — Bandel ticket-system integration (per-list import + status write-back),
inactive without ticket_api_base_url: TicketSystemConfig/TicketClientFactory,
BandelTicketClient, TicketDtos, TicketStatusMap, TicketStatusSync,
@@ -151,8 +153,8 @@ Full flow, invariants, and model/effort/max-turns resolution (including the low-
- **ClaudeProcess** — spawns `claude -p --output-format stream-json --verbose --permission-mode auto` (or whatever app settings specify). Prompt via stdin, NDJSON from stdout. CancellationToken kills the process tree. Resolves `_cfg.ClaudeBin` via `ExecutableResolver` (`ClaudeDo.Data`, shared with `ClaudeCliPreflight` and the Installer's checks) before spawning — a `.cmd`/`.bat` shim (e.g. an npm-installed `claude`) is launched through `cmd.exe /c` since `UseShellExecute = false` can't exec a shim directly; a resolved `.exe` starts exactly as before. Throws if nothing resolves. *(Not yet on `main` — see `Environment Checks` in `ClaudeDo.Installer/CLAUDE.md`.)*
- **ClaudeArgsBuilder** — `--model`, `--effort`, `--max-turns`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume`
- **StreamAnalyzer** — parses NDJSON; extracts session_id, token counts, turn counts, result text, structured output. Replaced MessageParser.
- **WorktreeManager** — worktrees on `<typ>/<nummer>-<slug>` branches, same leaf as the folder (`NameFor`, slug capped at 40 chars, umlauts transliterated). Typ from `BranchTypeFor` (`feat`→`feature`, `fix/refactor/docs/test/chore` as-is, anything else `chore`). Nummer = ticket id (own `TicketRef` or the parent's), else the task number — in a company repo (`CompanyRepo`, origin matches `company_remote_pattern`) without a ticket no number at all. A taken folder/branch name gets an 8-char task-id suffix, so the self-heal never force-deletes a foreign branch. Base = `rev-parse <unit branch>` for a child whose parent has an Active worktree, else `list_config.base_branch`, else the checked-out HEAD. Commits with semantic messages, updates DB with head commit + diff stats
- **CommitMessageBuilder** — `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId`; `BuildMerge` is the merge-commit variant (`{commitType}(slug): merge title` + trailer). **Every merge caller passes a blank commit message on purpose** — `TaskMergeService` fills in `BuildMerge` from the task's commit type and its list's name, which is the only place that knows both. Don't reintroduce a caller-side literal.
- **WorktreeManager** — worktrees on `<typ>/<nummer>-<slug>` branches, same leaf as the folder (`NameFor`, slug capped at 40 chars, umlauts transliterated). Typ from `BranchTypeFor` (`feat`→`feature`, `fix/refactor/docs/test/chore` as-is, anything else `chore`). Nummer = ticket id (own `TicketRef` or the parent's), else the task number — in a company repo (`CompanyRepo`, origin matches `company_remote_pattern`) without a ticket no number at all. A taken folder/branch name gets an 8-char task-id suffix, so the self-heal never force-deletes a foreign branch. Base = `rev-parse <unit branch>` for a child whose parent has an Active worktree, else `list_config.base_branch`, else the checked-out HEAD — in a company repo a unit root first fetches its basis (see Azure DevOps below). Commits with semantic messages, updates DB with head commit + diff stats
- **CommitMessageBuilder** — `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId` (no trailer in company repos — the convention forbids signature trailers and nothing reads it); `BuildMerge` is the merge-commit variant (`{commitType}(slug): merge title` + trailer). **Every merge caller passes a blank commit message on purpose** — `TaskMergeService` fills in `BuildMerge` from the task's commit type and its list's name, which is the only place that knows both. Don't reintroduce a caller-side literal.
- **TaskResetService** — discards a failed task's worktree, resets the row to Idle, preserves run history
- **AgentFileService** — manages `~/.claudeDo/agents/*.md`; list/refresh via SignalR
- **LogWriter** — async StreamWriter wrapper, auto-creates parent dirs
@@ -273,10 +275,10 @@ non-obvious, behaviour-changing. A fixed bug is git history, not a finding.
- `db_path`, `sandbox_root`, `log_root`
- `worktree_root_strategy` (`sibling` | `central`), `central_worktree_root`
- `queue_backstop_interval_ms` (30000) — also the gate/throttle recovery timer
- `company_remote_pattern` (`/_git/`) — Teilstring der origin-URL, an dem `CompanyRepo` ein Firmen-Repo erkennt; nur per Hand editierbar.
- `company_remote_pattern` (`/_git/`) — Teilstring der origin-URL, an dem `CompanyRepo` ein Firmen-Repo erkennt; nur per Hand editierbar. Schaltet Fetch vor dem Unit-Root, Approve = Push + Draft-PR und Commits ohne Trailer (siehe Azure DevOps).
- `signalr_port` (47821), `claude_bin`, `ticket_api_base_url` — the two UI-writable fields
(`claude_bin`: Settings → Dateien → CLAUDE CLI, hub `GetClaudeBin`/`SetClaudeBin`;
`ticket_api_base_url`: Settings → Ticketsystem, hub `GetTicketSettings`/`SetTicketApiBaseUrl`).
`ticket_api_base_url`: Settings → Externe Dienste, hub `GetTicketSettings`/`SetTicketApiBaseUrl`).
Both edit the DI-singleton `WorkerConfig` in place *and* persist via their own `SaveKey` call
(`SaveClaudeBin`/`SaveTicketApiBaseUrl`), so each applies without a restart. Every other field
here is hand-edit only.
@@ -341,6 +343,39 @@ distinguish the two singletons). The token never touches `worker.config.json` or
`TicketStatusMap.NameOf` supplies the canonical name.
⚠️ The base URL must be the **IP** — `bandelapis.fb-tuning.local` still refuses the connection.
## Azure DevOps (company repos)
Active when origin matches `company_remote_pattern` (live `git remote get-url`, no cache). The PAT
(scope Code Read & Write) is DPAPI-encrypted at `~/.claudeDo/azdo.pat` (DI marker
`AzureDevOpsPatStore`), set via Settings → Externe Dienste (hub `GetAzureDevOpsSettings` /
`SetAzureDevOpsToken` / `ClearAzureDevOpsToken` / `TestAzureDevOpsConnection` — the test reads the
first list whose origin matches and parses). Deliberately independent of the work-conventions
plugin's PAT file.
- **`AzureDevOpsClient`** — the only HTTP surface. `ParseRemote` splits at `/_git/` (API base =
everything before it, embedded credentials stripped, http(s) only). `GitAuthEnv(pat)` is the
env for every git network call: `GIT_TERMINAL_PROMPT=0`, `GCM_INTERACTIVE=never`, PAT as
`http.extraHeader` via `GIT_CONFIG_COUNT/KEY_0/VALUE_0` — never on a command line. API version
starts at 6.0 and retries once with the version an out-of-range error names. ⚠️ A rejected PAT
is HTTP **203** + an HTML login page, which `IsSuccessStatusCode` counts as success — handled
explicitly.
- **Fetch before a unit root** (`WorktreeManager.FetchCompanyBaseAsync`, once per unit root, never
per child): basis = `base_branch` → ADO `defaultBranch` → `origin/HEAD`; `git fetch origin
+refs/heads/<b>:refs/remotes/origin/<b>`; the worktree starts from `origin/<b>`. Local `<b>` is
pulled along best-effort: not checked out → `git fetch . origin/<b>:<b>` (no network, git
refuses a non-ff), checked out + clean → `merge --ff-only`, dirty/diverged → warning only. A
failed fetch warns and continues from the local branch — it never fails the task.
- **Approve** → `TaskMergeService.ApproveCompanyAsync` → `PullRequestPublisher`; details in
[review-merge](../../docs/explore-notes/review-merge.md). PR title
`<type>(<list-slug>): <title>, Resolves #<ticket>`; description
`Resolves [#id](https://ticket.fb-tuning.local/board/project/<p>/ticket/<id>)` (project id = walk
`GET /api/ticket/{id}` up until `parentTicketID` is null — a ticket's parent is its project, a
task's parent is a ticket, live-checked 2026-10-01; unknown → plain `Resolves #id`), the task
description, `Geprüft: …` with only what actually ran. Target = `base_branch`, else the ADO
default branch.
- Not done on purpose: PR status polling / auto-`Done`, other PR providers, worktree cleanup on
`Done`, fetch in private repos, a UI for `company_remote_pattern`.
## Notes
- The worker runs standalone — start it separately from the UI. Loopback only (127.0.0.1).
+4 -1
View File
@@ -8,7 +8,7 @@ namespace ClaudeDo.Worker;
/// <summary>
/// Persists a single secret encrypted with DPAPI (CurrentUser scope), one instance per file.
/// Windows-only. Used for the Online-Inbox refresh token (~/.claudeDo/online-inbox.token) and
/// the ticket-system PAT (~/.claudeDo/ticket.pat).
/// the ticket-system PAT (~/.claudeDo/ticket.pat) and the Azure DevOps PAT (~/.claudeDo/azdo.pat).
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class DpapiTokenStore
@@ -55,3 +55,6 @@ public sealed record OnlineRefreshTokenStore(DpapiTokenStore Store);
/// DI-Marker: der Personal Access Token des Ticketsystems.
public sealed record TicketPatStore(DpapiTokenStore Store);
/// DI-Marker: der Personal Access Token für Azure DevOps (Push + Draft-PR im Firmen-Repo).
public sealed record AzureDevOpsPatStore(DpapiTokenStore Store);
+8 -1
View File
@@ -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<ReviewTaskResult> 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)
+49 -3
View File
@@ -18,6 +18,7 @@ using ClaudeDo.Worker.Report;
using ClaudeDo.Worker.Report.Interfaces;
using ClaudeDo.Worker.Skills;
using ClaudeDo.Worker.State;
using ClaudeDo.Worker.AzureDevOps;
using ClaudeDo.Worker.Tickets;
using ClaudeDo.Worker.Usage;
using ClaudeDo.Worker.Usage.Interfaces;
@@ -76,6 +77,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly HandlerRoundState? _roundState;
private readonly ConnectedClientTracker? _clientTracker;
private readonly RunningOperationRegistry? _operations;
private readonly AzureDevOpsPatStore? _azdoPat;
private readonly AzureDevOpsClient? _azdo;
public WorkerHub(
QueueService queue,
@@ -118,7 +121,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
HandlerRestartCoordinator? handlerRestart = null,
HandlerRoundState? roundState = null,
ConnectedClientTracker? clientTracker = null,
RunningOperationRegistry? operations = null)
RunningOperationRegistry? operations = null,
AzureDevOpsPatStore? azdoPat = null,
AzureDevOpsClient? azdo = null)
{
_queue = queue;
_waker = waker;
@@ -161,6 +166,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
_roundState = roundState;
_clientTracker = clientTracker;
_operations = operations;
_azdoPat = azdoPat;
_azdo = azdo;
}
// Tracks connected UI clients so an out-of-band caller (external MCP's start_list_handler)
@@ -509,7 +516,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<MergePreviewDto> PreviewMerge(string taskId, string targetBranch)
@@ -721,7 +728,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)
@@ -1428,6 +1435,45 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
}
}
public AzureDevOpsSettingsDto GetAzureDevOpsSettings() => new(_azdoPat?.Store.Exists() == true);
public void SetAzureDevOpsToken(string token)
{
if (_azdoPat is null) return;
if (string.IsNullOrWhiteSpace(token)) _azdoPat.Store.Clear();
else _azdoPat.Store.Save(token.Trim());
}
public void ClearAzureDevOpsToken() => _azdoPat?.Store.Clear();
/// <summary>Reads the first company repo's ADO repository (default branch). Errors are answers, not throws — same reason as TestTicketConnection.</summary>
public async Task<AzureDevOpsConnectionDto> TestAzureDevOpsConnection()
{
if (_azdo is null) return new AzureDevOpsConnectionDto(false, null, null, "Azure DevOps ist im Worker nicht registriert.");
try
{
var git = _git ?? new Data.Git.GitService();
List<string> dirs;
await using (var ctx = await _dbFactory.CreateDbContextAsync(Context.ConnectionAborted))
dirs = await ctx.Lists.AsNoTracking().Where(l => l.WorkingDir != null).Select(l => l.WorkingDir!).ToListAsync(Context.ConnectionAborted);
foreach (var dir in dirs)
{
if (!Directory.Exists(dir)) continue;
var remote = await git.GetRemoteUrlAsync(dir, "origin", Context.ConnectionAborted);
if (!Git.CompanyRepo.Matches(remote, _cfg.CompanyRemotePattern) || AzureDevOpsClient.ParseRemote(remote) is not { } repo)
continue;
var branch = await _azdo.GetDefaultBranchAsync(repo, Context.ConnectionAborted);
return new AzureDevOpsConnectionDto(true, repo.Repo, branch, null);
}
return new AzureDevOpsConnectionDto(false, null, null, "Keine Liste zeigt auf ein Firmen-Repo (origin mit " + _cfg.CompanyRemotePattern + ").");
}
catch (Exception ex)
{
return new AzureDevOpsConnectionDto(false, null, null, ex.Message);
}
}
public async Task<List<TicketProjectDto>> GetTicketProjects()
{
var client = _ticketClients.Create();
@@ -3,6 +3,7 @@ using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.AzureDevOps;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.State;
@@ -15,13 +16,15 @@ namespace ClaudeDo.Worker.Lifecycle;
public sealed record MergeResult(
string Status,
IReadOnlyList<string> ConflictFiles,
string? ErrorMessage);
string? ErrorMessage,
string? PullRequestUrl = null);
public sealed record MergeTargets(
string DefaultBranch,
IReadOnlyList<string> LocalBranches,
string DefaultCommitMessage,
bool TargetDirty = false);
bool TargetDirty = false,
bool IsCompanyRepo = false);
// VerifyExitCode/VerifyDurationMs/VerifyOutputTail are only populated when the caller asked for
// a verify run (runVerify=true) AND the list has a verify command configured AND the preview came
@@ -76,6 +79,11 @@ public sealed class TaskMergeService
// autostash: true) without matching on prose.
public const string StatusDirtyTarget = "dirty_target";
// Company repo: approve pushed the unit branch and opened (or reused) its draft PR; the task stays
// WaitingForReview, nothing landed locally.
public const string StatusPrCreated = "pr_created";
public const string StatusPrUpdated = "pr_updated";
public const string StatusReverted = "reverted";
public const string StatusConflictAborted = "conflict_aborted";
@@ -119,6 +127,7 @@ public sealed class TaskMergeService
private readonly ILogger<TaskMergeService> _logger;
private readonly UnitBranchService? _unitBranch;
private readonly RunningOperationRegistry _operations;
private readonly PullRequestPublisher? _pullRequests;
public TaskMergeService(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
@@ -128,7 +137,8 @@ public sealed class TaskMergeService
IVerifyCommandRunner verify,
ILogger<TaskMergeService> logger,
UnitBranchService? unitBranch = null,
RunningOperationRegistry? operations = null)
RunningOperationRegistry? operations = null,
PullRequestPublisher? pullRequests = null)
{
_dbFactory = dbFactory;
_git = git;
@@ -138,6 +148,7 @@ public sealed class TaskMergeService
_logger = logger;
_unitBranch = unitBranch;
_operations = operations ?? new RunningOperationRegistry();
_pullRequests = pullRequests;
}
private IDisposable TrackTask(string kind, TaskEntity task) =>
@@ -893,7 +904,8 @@ public sealed class TaskMergeService
var current = await ResolveTargetAsync(list, null, ct);
var branches = await _git.ListLocalBranchesAsync(list.WorkingDir, ct);
var dirty = await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct);
return new MergeTargets(current, branches, defaultMessage, dirty);
var company = _pullRequests is not null && await _pullRequests.IsCompanyRepoAsync(list.WorkingDir, ct);
return new MergeTargets(current, branches, defaultMessage, dirty, company);
}
/// <summary>Leeres Ziel → <c>list_config.base_branch</c>, sonst der ausgecheckte Branch des Main-Checkouts.</summary>
@@ -1054,6 +1066,12 @@ public sealed class TaskMergeService
}
}
// Kept (parked or reverted) goes the PR way too: a local merge has no place in a company repo.
if (wt is { State: WorktreeState.Active or WorktreeState.Kept } && _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
@@ -1137,6 +1155,42 @@ public sealed class TaskMergeService
return await MergeAsync(taskId, target, removeWorktree: true, commitMessage: "", leaveConflictsInTree, ct, progress, skipVerify, autostash);
}
/// <summary>
/// Company repo: verify in the unit root's worktree (nothing lands on a local target, so there is no
/// merge to defer the verify to — <c>skipVerify</c> does not apply), then push + draft PR. Red verify
/// pushes nothing. Status, worktree state and ticket stay as they are.
/// </summary>
private async Task<MergeResult> ApproveCompanyAsync(
TaskEntity task, ListEntity list, WorktreeEntity wt, string? verifyCommand, CancellationToken ct,
IProgress<ProgressNotificationValue>? progress)
{
if (await _pullRequests!.CheckReadyAsync(list.WorkingDir!, ct) is { } notReady)
return Blocked(notReady);
var ranVerify = !string.IsNullOrWhiteSpace(verifyCommand);
if (ranVerify)
{
await _broadcaster.OperationProgress(task.Id, PhaseVerifying, 0, 0);
var failed = await RunVerifyGateAsync(
verifyCommand, wt.Path, () => TrackTask(OperationKinds.VerifyMerge, task), ct, progress,
elapsed => _ = _broadcaster.OperationProgress(task.Id, PhaseVerifying, (int)elapsed.TotalSeconds, 0));
if (failed is not null) return failed;
}
var result = await ProgressReporter.RunAsync(
_pullRequests.PublishAsync(task, list, wt, ranVerify ? verifyCommand : null, ct),
ProgressReportInterval, progress, "push + draft PR running");
// A branch with an open PR is under review again, whatever parked it before.
if (wt.State == WorktreeState.Kept && result.PullRequestUrl is not null)
{
using (var ctx = _dbFactory.CreateDbContext())
await new WorktreeRepository(ctx).SetStateAsync(task.Id, WorktreeState.Active, ct);
await _broadcaster.WorktreeUpdated(task.Id);
}
return result;
}
/// <summary>
/// Redoes a merge that <see cref="RevertMergeAsync"/> undid (or that predates it, if the branch
/// was already merged before its worktree reached <see cref="WorktreeState.Kept"/>), by
+5
View File
@@ -4,6 +4,7 @@ using ClaudeDo.Data.Git;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker;
using ClaudeDo.Worker.Agents;
using ClaudeDo.Worker.AzureDevOps;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Findings;
@@ -248,12 +249,16 @@ builder.Services.AddSingleton(cfg.OnlineInbox);
#pragma warning disable CA1416 // ClaudeDo.Worker is Windows-only; DPAPI is fine here.
builder.Services.AddSingleton(new OnlineRefreshTokenStore(DpapiTokenStore.InAppData("online-inbox.token")));
builder.Services.AddSingleton(new TicketPatStore(DpapiTokenStore.InAppData("ticket.pat")));
builder.Services.AddSingleton(new AzureDevOpsPatStore(DpapiTokenStore.InAppData("azdo.pat")));
#pragma warning restore CA1416
builder.Services.AddSingleton<TicketSystemConfig>();
builder.Services.AddHttpClient("tickets");
builder.Services.AddSingleton<TicketClientFactory>();
builder.Services.AddSingleton<TicketStatusSync>();
builder.Services.AddSingleton<TicketImportService>();
builder.Services.AddHttpClient("azdo", c => c.Timeout = TimeSpan.FromSeconds(30));
builder.Services.AddSingleton<AzureDevOpsClient>();
builder.Services.AddSingleton<PullRequestPublisher>();
if (cfg.OnlineInbox.Enabled)
{
@@ -6,7 +6,9 @@ namespace ClaudeDo.Worker.Runner;
public static class CommitMessageBuilder
{
public static string Build(string commitType, string listName, string taskTitle, string? taskDescription, string taskId)
/// <summary>Task commit message; <paramref name="includeTrailer"/> is false in company repos, whose convention forbids signature trailers.</summary>
public static string Build(string commitType, string listName, string taskTitle, string? taskDescription, string taskId,
bool includeTrailer = true)
{
var slug = ToSlug(listName);
var title = Truncate(taskTitle, 60);
@@ -22,9 +24,11 @@ public static class CommitMessageBuilder
sb.Append(Truncate(taskDescription!.Trim(), 400));
}
// Trailer is always included.
sb.Append("\n\n");
sb.Append($"ClaudeDo-Task: {taskId}");
if (includeTrailer)
{
sb.Append("\n\n");
sb.Append($"ClaudeDo-Task: {taskId}");
}
return sb.ToString();
}
+94 -8
View File
@@ -2,6 +2,7 @@ using ClaudeDo.Data;
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;
@@ -26,11 +27,12 @@ public sealed class WorktreeManager
private readonly ILogger<WorktreeManager> _logger;
private readonly HubBroadcaster? _broadcaster;
private readonly RunningOperationRegistry _operations;
private readonly AzureDevOpsClient? _azdo;
public WorktreeManager(
GitService git, IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerConfig cfg,
ILogger<WorktreeManager> logger, HubBroadcaster? broadcaster = null,
RunningOperationRegistry? operations = null)
RunningOperationRegistry? operations = null, AzureDevOpsClient? azdo = null)
{
_git = git;
_dbFactory = dbFactory;
@@ -38,6 +40,7 @@ public sealed class WorktreeManager
_logger = logger;
_broadcaster = broadcaster;
_operations = operations ?? new RunningOperationRegistry();
_azdo = azdo;
}
private Task BroadcastCreating(string taskId) =>
@@ -80,8 +83,8 @@ public sealed class WorktreeManager
if (await _git.EnsureHeadCommitAsync(workingDir, ct))
_logger.LogInformation("Repository at {Dir} had no commits; created an empty bootstrap commit", workingDir);
var baseCommit = await ResolveBaseCommitAsync(task, workingDir, ct);
var isCompany = await CompanyRepo.IsCompanyAsync(_git, workingDir, _cfg.CompanyRemotePattern, ct);
var baseCommit = await ResolveBaseCommitAsync(task, workingDir, isCompany, ct);
var name = NameFor(task, await ResolveTicketIdAsync(task, ct), isCompany);
var branchType = BranchTypeFor(task.CommitType);
var slug = CommitMessageBuilder.ToSlug(list.Name);
@@ -204,8 +207,10 @@ public sealed class WorktreeManager
await _git.AddAllAsync(ctx.WorktreePath, ct);
var isCompany = list.WorkingDir is not null
&& await CompanyRepo.IsCompanyAsync(_git, list.WorkingDir, _cfg.CompanyRemotePattern, ct);
var message = CommitMessageBuilder.Build(
task.CommitType, list.Name, task.Title, task.Description, task.Id);
task.CommitType, list.Name, task.Title, task.Description, task.Id, includeTrailer: !isCompany);
await _git.CommitAsync(ctx.WorktreePath, message, ct);
@@ -264,9 +269,10 @@ public sealed class WorktreeManager
/// <summary>
/// A child of a parent with an Active worktree starts from the unit branch's current head, so it
/// sees every sibling absorbed before it. Unit roots (and legacy children) base off
/// <c>list_config.base_branch</c>, else the current HEAD.
/// <c>list_config.base_branch</c>, else the current HEAD — in a company repo off a freshly fetched
/// <c>origin/&lt;basis&gt;</c> (<see cref="FetchCompanyBaseAsync"/>).
/// </summary>
private async Task<string> ResolveBaseCommitAsync(TaskEntity task, string workingDir, CancellationToken ct)
private async Task<string> ResolveBaseCommitAsync(TaskEntity task, string workingDir, bool isCompany, CancellationToken ct)
{
if (task.ParentTaskId is not null)
{
@@ -276,12 +282,92 @@ public sealed class WorktreeManager
return await _git.RevParseAsync(workingDir, parentWt.BranchName, ct);
}
using var cfgCtx = _dbFactory.CreateDbContext();
var baseBranch = await cfgCtx.ListConfigs.AsNoTracking()
var baseBranch = (await cfgCtx.ListConfigs.AsNoTracking()
.Where(c => c.ListId == task.ListId)
.Select(c => c.BaseBranch)
.FirstOrDefaultAsync(ct);
return string.IsNullOrWhiteSpace(baseBranch)
.FirstOrDefaultAsync(ct)).NullIfBlank();
if (isCompany)
{
try
{
if (await FetchCompanyBaseAsync(workingDir, baseBranch, ct) is { } fetched) return fetched;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Fetching the company base in {working_dir} failed, continuing with the local branch", workingDir);
}
}
return baseBranch is null
? await _git.RevParseHeadAsync(workingDir, ct)
: await _git.RevParseAsync(workingDir, baseBranch, ct);
}
/// <summary>
/// Fetches the basis (<c>base_branch</c>, else the ADO repo's default branch, else <c>origin/HEAD</c>) and
/// returns <c>origin/&lt;basis&gt;</c>'s commit; the local basis is fast-forwarded best-effort. Null when no
/// basis is known or the fetch failed (offline, no credentials) — the caller then uses the local basis,
/// a fetch problem never fails the task.
/// </summary>
private async Task<string?> FetchCompanyBaseAsync(string workingDir, string? baseBranch, CancellationToken ct)
{
var remote = await _git.GetRemoteUrlAsync(workingDir, "origin", ct);
var pat = _azdo?.Pat;
var basis = baseBranch;
if (basis is null && _azdo is not null && pat is not null && AzureDevOpsClient.ParseRemote(remote) is { } repo)
{
try { basis = await _azdo.GetDefaultBranchAsync(repo, ct); }
catch (Exception ex) when (ex is AzureDevOpsException or InvalidOperationException) { _logger.LogWarning("Default branch from Azure DevOps unavailable: {reason}", ex.Message); }
}
basis ??= await _git.GetRemoteHeadBranchAsync(workingDir, ct);
if (basis is null)
{
_logger.LogWarning("Company repo {working_dir}: no base branch known, worktree starts from HEAD without fetch", workingDir);
return null;
}
var (fetchExit, fetchErr) = await _git.FetchBranchAsync(workingDir, basis, AzureDevOpsClient.GitAuthEnv(pat), ct);
if (fetchExit != 0)
{
_logger.LogWarning("git fetch origin {branch} failed in {working_dir}, continuing with the local branch: {reason}",
basis, workingDir, fetchErr);
return baseBranch is null ? await TryRevParseAsync(workingDir, basis, ct) : null;
}
await FastForwardLocalBaseAsync(workingDir, basis, ct);
return await _git.RevParseAsync(workingDir, $"refs/remotes/origin/{basis}", ct);
}
/// <summary>Pulls the local basis up to <c>origin/&lt;basis&gt;</c> without ever touching a dirty or diverged checkout.</summary>
private async Task FastForwardLocalBaseAsync(string workingDir, string basis, CancellationToken ct)
{
string? current;
try { current = await _git.GetCurrentBranchAsync(workingDir, ct); }
catch (InvalidOperationException) { current = null; } // detached HEAD
(int ExitCode, string Stderr) result;
if (current == basis)
{
if (await _git.HasChangesAsync(workingDir, includeUntracked: false, ct))
{
_logger.LogWarning("Local {branch} in {working_dir} has uncommitted changes, not fast-forwarded to origin", basis, workingDir);
return;
}
result = await _git.MergeFastForwardOnlyAsync(workingDir, $"refs/remotes/origin/{basis}", ct);
}
else
{
if (!(await _git.ListLocalBranchesAsync(workingDir, ct)).Contains(basis)) return;
result = await _git.FastForwardLocalBranchAsync(workingDir, basis, ct);
}
if (result.ExitCode != 0)
_logger.LogWarning("Local {branch} in {working_dir} not fast-forwarded to origin: {reason}", basis, workingDir, result.Stderr);
}
private async Task<string?> TryRevParseAsync(string workingDir, string revision, CancellationToken ct)
{
try { return await _git.RevParseAsync(workingDir, revision, ct); }
catch (InvalidOperationException) { return null; }
}
}
@@ -212,6 +212,16 @@ public class PromptFilesTests
Assert.Contains("Never use raw `git merge`", d);
}
[Fact]
public void DefaultFor_merge_helper_merge_explains_company_repo_draft_prs()
{
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperMerge);
Assert.Contains("pr_created", d);
Assert.Contains("pr_updated", d);
Assert.Contains("do NOT call verify_merges", d);
Assert.Contains("list every PR link", d);
}
[Fact]
public void DefaultFor_merge_helper_merge_runs_all_merges_before_any_rerun()
{
@@ -232,6 +232,10 @@ public abstract class StubWorkerClient : IWorkerClient
public virtual Task SetTicketTokenAsync(string token) => Task.CompletedTask;
public virtual Task ClearTicketTokenAsync() => Task.CompletedTask;
public virtual Task<TicketConnectionDto?> TestTicketConnectionAsync() => Task.FromResult<TicketConnectionDto?>(null);
public virtual Task<AzureDevOpsSettingsDto?> GetAzureDevOpsSettingsAsync() => Task.FromResult<AzureDevOpsSettingsDto?>(null);
public virtual Task SetAzureDevOpsTokenAsync(string token) => Task.CompletedTask;
public virtual Task ClearAzureDevOpsTokenAsync() => Task.CompletedTask;
public virtual Task<AzureDevOpsConnectionDto?> TestAzureDevOpsConnectionAsync() => Task.FromResult<AzureDevOpsConnectionDto?>(null);
public virtual Task<List<TicketProjectDto>> GetTicketProjectsAsync() => Task.FromResult(new List<TicketProjectDto>());
public virtual Task<TicketImportResultDto> ImportTicketsAsync(string listId) => Task.FromResult(new TicketImportResultDto(0, 0));
@@ -0,0 +1,46 @@
using ClaudeDo.Ui.ViewModels.Islands;
using Xunit;
namespace ClaudeDo.Ui.Tests.ViewModels;
/// <summary>Detail pane in a company repo: approve becomes "create draft PR" / "update PR", and the PR link only exists once there is a PR.</summary>
public sealed class MergeSectionCompanyRepoTests
{
private sealed class Worker : StubWorkerClient { }
private static MergeSectionViewModel Vm() => new(new Worker(), services: null!);
[Fact]
public void PrivateRepo_ShowsOnlyApproveAndMerge()
{
var vm = Vm();
Assert.True(vm.ShowApproveMerge);
Assert.False(vm.ShowCreatePr);
Assert.False(vm.ShowUpdatePr);
Assert.False(vm.OpenPullRequestCommand.CanExecute(null));
}
[Fact]
public void CompanyRepo_WithoutPr_OffersCreateDraftPr()
{
var vm = Vm();
vm.IsCompanyRepo = true;
Assert.False(vm.ShowApproveMerge);
Assert.True(vm.ShowCreatePr);
Assert.False(vm.ShowUpdatePr);
}
[Fact]
public void CompanyRepo_WithPr_OffersUpdateAndLink()
{
var vm = Vm();
vm.IsCompanyRepo = true;
vm.PullRequestUrl = "https://srv/Coll/Proj/_git/Repo/pullrequest/5";
Assert.False(vm.ShowCreatePr);
Assert.True(vm.ShowUpdatePr);
Assert.True(vm.OpenPullRequestCommand.CanExecute(null));
}
}
@@ -34,6 +34,44 @@ public sealed class TicketSettingsTabViewModelTests : IDisposable
public List<string?> SavedFallbacks { get; } = new();
public override Task<TicketSettingsDto?> GetTicketSettingsAsync() => Task.FromResult(Settings);
public override Task SetTicketFallbackListAsync(string? listId) { SavedFallbacks.Add(listId); return Task.CompletedTask; }
public List<string> SavedAzdoTokens { get; } = new();
public AzureDevOpsConnectionDto? AzdoConnection { get; set; }
public override Task<AzureDevOpsSettingsDto?> GetAzureDevOpsSettingsAsync() => Task.FromResult<AzureDevOpsSettingsDto?>(new(false));
public override Task SetAzureDevOpsTokenAsync(string token) { SavedAzdoTokens.Add(token); return Task.CompletedTask; }
public override Task<AzureDevOpsConnectionDto?> TestAzureDevOpsConnectionAsync() => Task.FromResult(AzdoConnection);
}
[Fact]
public async Task Save_writes_a_typed_azdo_token_once_and_never_keeps_it_in_the_field()
{
var worker = new Worker { Settings = new TicketSettingsDto("http://api", true, null) };
var vm = new TicketSettingsTabViewModel(worker, new TestDbFactory(NewContext));
await vm.LoadAsync();
Assert.False(vm.AzdoTokenSet);
vm.AzdoToken = "azdo-pat";
await vm.SaveAsync();
await vm.SaveAsync();
Assert.Equal(["azdo-pat"], worker.SavedAzdoTokens);
Assert.Equal("", vm.AzdoToken);
Assert.True(vm.AzdoTokenSet);
}
[Fact]
public async Task Azdo_test_reports_the_server_error_verbatim()
{
var worker = new Worker
{
Settings = new TicketSettingsDto("http://api", true, null),
AzdoConnection = new AzureDevOpsConnectionDto(false, null, null, "Azure DevOps 401: PAT abgelaufen"),
};
var vm = new TicketSettingsTabViewModel(worker, new TestDbFactory(NewContext));
await vm.LoadAsync();
await vm.TestAzdoConnectionCommand.ExecuteAsync(null);
Assert.Equal("Azure DevOps 401: PAT abgelaufen", vm.AzdoStatusMessage);
}
[Fact]
@@ -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/&lt;basis&gt;. 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()
{