12 KiB
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 topull_request_url.dotnet ef migrations add AddWorktreePullRequestUrl(projectsrc/ClaudeDo.Data). Check the generated Designer containsbase_branch(parentAddListBaseBranch) — see trapef-migration-parallel-designer-collision.WorktreeRepository.SetPullRequestUrlAsync(taskId, url, ct)viaExecuteUpdateAsync.- Covered by Task 5 tests.
Task 2: Git primitives + trailer
RunGitAsync(..., IReadOnlyDictionary<string,string>? env = null); network ops always setGIT_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/HEADminusorigin/, or null.CommitMessageBuilder.Build(..., bool includeTrailer = true);WorktreeManager.CommitIfChangedAsyncpasses!isCompany. Test inCommitMessageBuilderTests: noClaudeDo-Task:line when false.
Task 3: AzureDevOpsClient + PAT store
AzureDevOpsPatStore(DpapiTokenStore)marker, registered withInAppData("azdo.pat").AzureDevOpsClient(IHttpClientFactory, AzureDevOpsPatStore):static AzdoRepo? ParseRemote(string remote)— trim, strip.git, stripuser[:pw]@, split at/_git/;Repounescaped.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>→defaultBranchwithoutrefs/heads/.EnsureDraftPullRequestAsync(repo, source, target, title, description, ct)→GET …/pullrequests?searchCriteria.sourceRefName=refs/heads/<s>&searchCriteria.status=active; first hit is reused, elsePOST …/pullrequestswithisDraft: true. Returns(Url, Created), URL<webUrl>/pullrequest/<id>.- api version starts at
6.0, one retry with the version the error names (remembered). - errors →
AzureDevOpsExceptionwith status code + server message; no PAT →AzureDevOpsException("Kein Azure-DevOps-PAT hinterlegt …").
- Tests (
AzureDevOpsClientTests,StubHandlerfrom Tickets): the two-SelfTestremotes + non-ADO remote, auth headerBasic OmdlaGVpbQ==, version parse (match / foreign error), reuse of an active PR (no POST), create withisDraft+ refs, version fallback retry.
Task 4: Fetch the basis before a unit root
WorktreeManager.ResolveBaseCommitAsync, unit-root branch only, when CompanyRepo.IsCompanyAsync:
- basis =
base_branch→ ADOdefaultBranch(if the remote parses and a PAT is set) →GetRemoteHeadBranchAsync. None → today's behaviour. FetchBranchAsync(basis, GitAuthEnv(pat)). Failure → warning, base = localbasis(else HEAD). No throw.- 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'sBaseCommit== bare head, localmainfast-forwarded; dirty checked-outmainstays untouched (file content + HEAD unchanged) but the root still starts from origin; unreachable origin → worktree created from localmain; 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 inGetTargetsAsync.ApproveAndMergeAsync: after the child-absorb block, ifwtis Active and the repo is a company repo →ApproveCompanyAsync(ignoresskipVerify):- verify via
RunVerifyGateAsync(verifyCommand, wt.Path, …)withPhaseVerifyingbroadcast; red →verify_failed, nothing pushed. PullRequestPublisher.PublishAsync(task, list, wt, verified, ct)wrapped inProgressReporter.RunAsync("push + draft PR running").
- verify via
PullRequestPublisher(GitService,AzureDevOpsClient,TicketClientFactory?, db factory, broadcaster, logger):- no PAT → blocked; remote does not parse → blocked.
- push with
GitAuthEnv; rejected → blockedpush rejected: <stderr>. - target =
base_branchelse ADO default branch. - title
<type>(<list-slug>): <title>[, Resolves #<ticket>]; descriptionResolves [#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_updatedwith URL. Task status and worktree state untouched.
- Hub
ApproveReview:MergeResultDto(..., r.PullRequestUrl). MCPreview_task:mergeMessage= PR URL forpr_*; tool description mentions it. - Tests (
CompanyApproveTests; origin URLhttp://ado.test/coll/proj/_git/Repo+url.<bare>.pushInsteadOfso 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_updatedwithout POST, verify red → no push, PR error after push → blocked "gepusht, PR fehlgeschlagen",skipVerifyignored.
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) andMerge.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 returnspr_created/pr_updated+ URL, task stays WaitingForReview, noverify_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.