From 2b2f61d7fc66c6f3931b794b81199d08d7bf8a42 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 1 Oct 2026 11:48:11 +0200 Subject: [PATCH] docs(planning): plan for unit branch part 3 (company mode, draft PR) --- .../2026-10-01-unit-branch-3-company-pr.md | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-01-unit-branch-3-company-pr.md diff --git a/docs/superpowers/plans/2026-10-01-unit-branch-3-company-pr.md b/docs/superpowers/plans/2026-10-01-unit-branch-3-company-pr.md new file mode 100644 index 00000000..193b07f0 --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-unit-branch-3-company-pr.md @@ -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/`, 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//ticket/`. + +## 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? env = null)`; network ops always set `GIT_TERMINAL_PROMPT=0`. +- [ ] `FetchBranchAsync(repoDir, branch, env, ct)` → `git fetch origin +refs/heads/:refs/remotes/origin/` (explicit refspec: works without a configured fetch refspec); returns `(ExitCode, Stderr)`. +- [ ] `FastForwardLocalBranchAsync(repoDir, branch, ct)` → `git fetch . refs/remotes/origin/:refs/heads/` (local, no network; git refuses a non-fast-forward itself). +- [ ] `PushBranchAsync(worktreePath, branch, env, ct)` → `git push -u origin `. +- [ ] `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 GitAuthEnv(string? pat)` — `GIT_TERMINAL_PROMPT=0` + (pat ? `GIT_CONFIG_COUNT=1`, `GIT_CONFIG_KEY_0=http.extraHeader`, `GIT_CONFIG_VALUE_0=Authorization: Basic `). + - `static string? SupportedVersion(string error)` — `supports is (\d+\.\d+)`. + - `GetDefaultBranchAsync(repo, ct)` → `GET …/_apis/git/repositories/` → `defaultBranch` without `refs/heads/`. + - `EnsureDraftPullRequestAsync(repo, source, target, title, description, ct)` → `GET …/pullrequests?searchCriteria.sourceRefName=refs/heads/&searchCriteria.status=active`; first hit is reused, else `POST …/pullrequests` with `isDraft: true`. Returns `(Url, Created)`, URL `/pullrequest/`. + - 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/`; then best-effort local update: not checked out → `FastForwardLocalBranchAsync` (only if the local branch exists); checked out + clean (`HasChangesAsync(includeUntracked:false)`) → `MergeFastForwardOnlyAsync(workingDir, origin/)`; 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: `. + - target = `base_branch` else ADO default branch. + - 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.