Files
ClaudeDo/docs/superpowers/plans/2026-10-01-unit-branch-3-company-pr.md
T

12 KiB
Raw Blame History

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.