113 lines
12 KiB
Markdown
113 lines
12 KiB
Markdown
# 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.
|