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

113 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.