250 lines
16 KiB
Markdown
250 lines
16 KiB
Markdown
# Unit-Branch + Draft-PR — Design
|
|
|
|
**Datum:** 2026-10-01 · **Status:** abgenommen (mit Basis-Branch pro Liste + Fetch)
|
|
**Diagramme:** Obsidian `07 - Plattform & Technik/ClaudeDo/ClaudeDo - Branch und PR Ablauf.md`
|
|
**Ersetzt:** die offene Entscheidung aus `2026-08-06-planning-chain-fork-base-design.md`
|
|
|
|
## Ziel
|
|
|
|
Aufgaben starten und später zu fertigen PRs zurückkommen. In Firmen-Repos (Azure DevOps Server)
|
|
heißen Branches nach dem work-conventions-Schema, und das Review endet in einem **Draft-PR** statt
|
|
in einem lokalen Merge auf `main`. Nebenbei sehen Planning-Children endlich die Arbeit ihres
|
|
Vorgängers.
|
|
|
|
## Begriffe
|
|
|
|
- **Unit:** ein einzelner Task, oder ein Parent (Planning oder Improvement) samt Children.
|
|
- **Unit-Branch:** der eine Branch, auf dem die ganze Arbeit einer Unit landet. Er gehört dem
|
|
Worktree des Unit-Roots (Einzeltask bzw. Parent).
|
|
- **Firmen-Repo:** Die `origin`-URL enthält `company_remote_pattern` (`worker.config.json`,
|
|
Default `/_git/`, nur per Hand editierbar). Geprüft wird live über `git remote get-url origin`
|
|
und ohne Cache.
|
|
|
|
## Entscheidungen
|
|
|
|
| Frage | Entscheidung |
|
|
|---|---|
|
|
| Branchname | Einheitlich `<typ>/<nummer>-<slug>`. Die Nummer ist die Ticket-Id aus `TicketRef` (beim Child die des Unit-Roots), sonst privat die Task-Nummer. Im Firmen-Repo ohne Ticket entfällt sie (`<typ>/<slug>`). `claudedo/{nr}-…` fällt weg. Planning-Session-Worktrees bleiben `claudedo/planning/{guid}`. |
|
|
| Typ | `feat`→`feature`. `fix`, `refactor`, `docs`, `test`, `chore` bleiben, wie sie sind. Alles andere (`ci`, `perf`, `style`, `build`) wird `chore`. |
|
|
| Slug | `CommitMessageBuilder.ToSlug` plus Umlaut-Auflösung (`ä→ae`, `ö→oe`, `ü→ue`, `ß→ss`), ≤40 Zeichen wie heute. |
|
|
| Children | Starten vom aktuellen Head des Unit-Branches. Ist ein Child fertig, wird der Unit-Branch sofort per **fast-forward** auf den Head des Childs gezogen. Das gilt privat und in Firmen-Repos. |
|
|
| Review-Ende privat | Unverändert: Approve merged den Unit-Branch auf `main`, dann läuft Verify auf `main`. |
|
|
| Review-Ende Firma | Approve führt Verify im Worktree des Unit-Roots aus, dann Push und Draft-PR. Der Task bleibt `WaitingForReview` mit PR-Link. `Done` setzt Mika per Toggle nach dem ADO-Merge. |
|
|
| PR-Automatik | Kein Auto-PR bei einem Statuswechsel. Automatisiert wird über den Merge-Helper, dessen `approve` dann den PR erzeugt. |
|
|
| ADO-Zugang | Eigener PAT (Scope Code Read & Write). Er wird per `DpapiTokenStore` in `~/.claudeDo/azdo.pat` gespeichert, ohne Abhängigkeit auf das work-conventions-Plugin. |
|
|
| dependsOn über Units | Wartet bewusst auf den echten Merge. Abhängige Arbeit gehört in eine Unit mit einem PR. |
|
|
| Basis-Branch | Pro Liste einstellbar (`list_config.base_branch`, z.B. `dev` für die LagerApp). Leer heißt: privat der gerade ausgecheckte Branch des Main-Checkouts (wie heute), Firma der Default-Branch des ADO-Repos. Gilt als Basis des Unit-Branches, als PR-Ziel und als Vorauswahl des privaten Merge-Ziels. |
|
|
| Basis aktualisieren | Firmen-Repo: `git fetch origin <basis>`, bevor der Unit-Branch angelegt wird. Der Unit-Branch startet von `origin/<basis>`, der lokale Basis-Branch wird best-effort per fast-forward nachgezogen. |
|
|
|
|
## 1. Branch-Naming (`WorktreeManager`)
|
|
|
|
- `NameFor(task, ticketId, isCompany)` liefert den Leaf `<nummer>-<slug>` bzw. `<slug>`, und
|
|
`BranchFor` liefert `<typ>/<leaf>`. Der Ordnername des Worktrees bleibt der Leaf.
|
|
- **Kollisionsprüfung** (heute `Contains($"claudedo/{name}")`, Zeile 83) prüft den neuen vollen
|
|
Branchnamen. Bei einem Treffer kommt das Id-Suffix dran, wie heute.
|
|
- **Self-Heal** (`git branch -D` nach „already exists“) läuft nur noch, wenn der Branch in einer
|
|
`worktrees`-Zeile steht, deren Task der aktuelle Task ist. Sonst gibt es keinen Delete, sondern
|
|
das Id-Suffix, und der Branch des Nutzers bleibt unangetastet. Ohne das Präfix `claudedo/` ist
|
|
das Pflicht, sonst löscht ClaudeDo einen fremden `feature/…`-Branch.
|
|
- **`UntrackedWorktreeRecovery`** filtert statt nach dem Präfix nach „Worktree-Pfad liegt unter
|
|
einer ClaudeDo-Worktree-Root (`.claudedo-worktrees` bzw. Central-Root), aber nicht unter
|
|
`planning/`“.
|
|
- `PlanningAggregator.CleanupIntegrationBranchAsync:137` (Präfix-Check beim Checkout-Fallback)
|
|
wird mit Abschnitt 3 ohnehin hinfällig.
|
|
- Bestehende Worktrees behalten ihren Namen. Der Branchname steht in der DB und wird nie neu
|
|
abgeleitet, eine Migration ist also nicht nötig.
|
|
- **Bugfix nebenbei:** `DetailsIslandViewModel.cs:1223/1385` baut für Dialogtexte
|
|
`claudedo/{Task.Id}`, das ist schon heute falsch. Künftig steht dort `worktree.BranchName`. Das
|
|
literale `"← main"` (`:670/:933`) zeigt das echte Ziel an.
|
|
|
|
## 2. Unit-Branch und Children
|
|
|
|
**Wer besitzt den Unit-Branch?**
|
|
- **Einzeltask:** sein eigener Worktree. Am Ablauf ändert sich nichts.
|
|
- **Improvement-Parent:** sein eigener Worktree, den er schon heute hat. Seine Children starten
|
|
schon heute von `parent.HeadCommit`.
|
|
- **Planning-Parent:** hat heute **keinen** Worktree. Neu: `QueuePlanAsync` legt per
|
|
`WorktreeManager.CreateAsync` einen Worktree für den Parent an, auf dem Unit-Branch und mit
|
|
`main` HEAD als Basis. Damit hat jeder Parent eine `worktrees`-Zeile, die den Unit-Branch und
|
|
später die PR-URL trägt.
|
|
|
|
**Basis eines Childs** (`ResolveBaseCommitAsync`): Das ist immer der aktuelle Head des
|
|
Unit-Branches (`rev-parse <unitBranch>`). Der Sonderfall „nur bei `PlanningPhase.None`“ fällt weg.
|
|
|
|
**Basis eines Unit-Roots** (Einzeltask, Parent):
|
|
- **Privat:** `list_config.base_branch`, sonst der ausgecheckte Branch des Main-Checkouts. Heute
|
|
ist es `RevParseHeadAsync`. Ist `base_branch` gesetzt, wird es `rev-parse <base_branch>`, auch
|
|
wenn gerade ein anderer Branch ausgecheckt ist.
|
|
- **Firma:**
|
|
- Zuerst `git fetch origin <basis>`. `<basis>` ist `base_branch`, sonst der Default-Branch des
|
|
Repos per ADO-API, ersatzweise `git symbolic-ref refs/remotes/origin/HEAD`.
|
|
- Der Unit-Branch startet von `origin/<basis>`.
|
|
- Dann wird der lokale `<basis>` best-effort nachgezogen:
|
|
- nicht ausgecheckt: `git fetch origin <basis>:<basis>` (lehnt einen nicht-fast-forward
|
|
selbst ab)
|
|
- ausgecheckt und sauber: `git -C <workingDir> merge --ff-only origin/<basis>`
|
|
- schmutzig oder divergiert: Warnung ins Log, kein Eingriff in den Checkout
|
|
- Schlägt der Fetch fehl (offline, keine Credentials), wird gewarnt und es geht mit dem
|
|
lokalen `<basis>` weiter. Der Task scheitert daran nicht.
|
|
- Der Fetch nutzt dieselbe PAT-Übergabe wie der Push (Abschnitt 3).
|
|
- Der Fetch läuft einmal pro Unit-Root, nicht pro Child. Children starten vom Unit-Branch.
|
|
|
|
**Spalte `list_config.base_branch`** (nullable string, Migration):
|
|
- Sie wird im Listeneinstellungs-Modal gepflegt, als Freitext mit Vorschlägen aus
|
|
`ListLocalBranchesAsync`, und in `get_list_config` mitgeliefert.
|
|
- ⚠️ `ListRepository.SetConfigAsync` kopiert verbatim (siehe `TicketProjectId`). Jeder Schreiber
|
|
muss den Wert durchreichen, sonst setzt ihn z.B. `set_list_config` still zurück:
|
|
`WorkerHub.UpdateListConfig`, `ConfigMcpTools`, alle Stellen, die eine frische
|
|
`ListConfigEntity` bauen.
|
|
- `UpdateListConfigDto` braucht ihn tri-state wie `TicketProjectId`, damit ein Aufrufer, der das
|
|
Feld nicht kennt, es nicht löscht.
|
|
- **Merge-Ziel privat:** `TaskMergeService.GetTargetsAsync` setzt `DefaultBranch` auf
|
|
`base_branch`, falls gesetzt. Das leere Ziel (`ApproveAndMergeAsync:1077`, `PreviewAsync:910`)
|
|
löst ebenfalls auf `base_branch` auf, sonst wie heute.
|
|
|
|
**Absorbieren eines Childs:** Neu ist `UnitBranchService.AbsorbChildAsync(childId)`, das in
|
|
`git -C <parentWorktree> merge --ff-only <childBranch>` mündet.
|
|
- Die Kette ist seriell (`BlockedByTaskId`), und das Child startet vom Head des Unit-Branches.
|
|
Deshalb ist es **immer** ein fast-forward, Konflikte sind ausgeschlossen. Lässt es sich nicht
|
|
fast-forwarden, z.B. weil jemand ein Child manuell parallel per „Run now“ gestartet hat, wird
|
|
das Child `Failed` mit einer klaren Meldung, ohne Merge-Versuch.
|
|
`ponytail: kein Merge-Fallback; einbauen, falls parallele Children ein echter Fall werden.`
|
|
- Danach wird der Worktree des Childs entfernt, der Branch gelöscht und die Zeile auf `Merged`
|
|
gesetzt (`MergeCommit` = null, denn es gibt keinen Merge-Commit). `revert_merge` greift auf
|
|
Child-Ebene deshalb nicht, revertiert wird die ganze Unit.
|
|
- Aufgerufen wird es aus `TaskRunner.HandleSuccess` direkt vor `_state.CompleteAsync`. Ein
|
|
ff-Fehler wird so zu `FailAsync` statt zu einem Unblock des Nachfolgers.
|
|
- Interaktive Children landen in `WaitingForReview`, und ihr Approve geht durch
|
|
`TaskMergeService.ApproveAndMergeAsync`. Dort gilt neu: Hat der Task einen Parent mit eigenem
|
|
Worktree, wird `AbsorbChildAsync` aufgerufen statt auf das Ziel gemergt.
|
|
- Kein Git-Code in `TaskStateService` oder `PlanningChainCoordinator`, die bleiben git-frei.
|
|
|
|
**Was dabei wegfällt:** Den Unit-Merge Child für Child am Ende gibt es für neue Units nicht mehr.
|
|
Ein Parent wird beim Approve wie ein Einzeltask behandelt, über seinen eigenen Worktree in
|
|
`TaskMergeService.ApproveAndMergeAsync`. Auch der Integration-Branch
|
|
(`PlanningAggregator`, `planning/{slug}-integration`, dafür `checkout -b` im Main-Checkout) ist
|
|
für neue Units unnötig, denn der kombinierte Diff ist `unitBase..unitHead`.
|
|
|
|
**Routing beim Approve** (`WorkerHub.ApproveReview`, `ExternalMcpService.ReviewTask`): Heute
|
|
geht jeder Task mit Children an den Orchestrator. Neu gilt: Gibt es ein `Done`-Child mit einem
|
|
`Active`-Worktree, also eins, das nicht absorbiert wurde, läuft der Legacy-Orchestrator. Sonst
|
|
geht es über `ApproveAndMergeAsync` auf den Worktree des Parents.
|
|
|
|
**Altbestand:** Parents ohne eigenen Worktree (vor dem Rollout geplant) laufen weiter über
|
|
`PlanningMergeOrchestrator` und den bestehenden Aggregator. Diesen Legacy-Pfad entfernen wir in
|
|
einem Folge-Task, sobald keine solchen Units mehr offen sind.
|
|
|
|
## 3. Approve im Firmen-Repo
|
|
|
|
`TaskMergeService.ApproveAndMergeAsync` verzweigt nach `IsCompanyRepo(list)`:
|
|
|
|
1. **Verify** im Worktree des Unit-Roots (`IVerifyCommandRunner.RunAsync(worktreePath, …)`), nicht
|
|
im Main-Checkout. Ohne Verify-Command entfällt der Schritt. Rot heißt: Ergebnis
|
|
`verify_failed`, der Task bleibt `WaitingForReview`, kein Push. `skipVerify` wird im
|
|
Firmen-Modus ignoriert, weil kein Merge landet, auf den man das Verify verschieben könnte.
|
|
2. **Push** mit `git -C <worktree> push -u origin <unitBranch>`. Der PAT geht als
|
|
`http.extraHeader` über `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_0`/`GIT_CONFIG_VALUE_0` in der
|
|
Prozess-Umgebung mit. Er steht also nicht in der Kommandozeile, und es gibt keinen
|
|
Credential-Manager-Prompt im Hintergrundprozess.
|
|
3. **PR**, neuer `AzureDevOpsClient`, portiert aus `pr-create.ps1`:
|
|
- Der Remote wird an `/_git/` zerlegt: API-Basis = alles davor, Repo = Segment danach.
|
|
Eingebettete Zugangsdaten fliegen raus.
|
|
- `GET …/pullrequests?searchCriteria.sourceRefName=refs/heads/<branch>&searchCriteria.status=active`.
|
|
Gibt es schon einen PR, wird er wiederverwendet (idempotent, auch wenn der Skill in der
|
|
Session schon einen angelegt hat).
|
|
- Sonst `POST …/pullrequests` mit `isDraft: true` und `targetRefName = <basis>`. Das ist
|
|
`list_config.base_branch`, sonst `defaultBranch` aus `GET …/repositories/<repo>`.
|
|
- Die API-Version fällt auf die zurück, die der Server in seiner Fehlermeldung nennt, wie im
|
|
Skript.
|
|
4. Die **PR-URL** wird gespeichert, in der neuen nullable Spalte `worktrees.pull_request_url`
|
|
(Migration). Danach folgen `WorktreeUpdated` und der Rückgabestatus `pr_created` bzw.
|
|
`pr_updated`.
|
|
5. Der Task bleibt `WaitingForReview`, der Worktree bleibt `Active`, und das Ticket bleibt auf
|
|
`InBearbeitung`.
|
|
|
|
**PR-Inhalt:**
|
|
- **Titel:** `<commitType>(<list-slug>): <titel>, Resolves #<ticket>`. Ohne Ticket entfällt
|
|
`Resolves`.
|
|
- **Beschreibung:**
|
|
- `Resolves [#412](<ticket-url>)`. Die Projekt-Id kommt über `BandelTicketClient.GetTicketAsync`
|
|
→ `ParentTicketID` (bei Tasks eine Ebene höher). Ohne Id gibt es keinen Link.
|
|
- Leerzeile, dann die Task-Beschreibung.
|
|
- Leerzeile, dann `Geprüft: <verify command> grün` bzw. `Geprüft: nichts automatisch`.
|
|
|
|
**Commits im Firmen-Repo:** `CommitMessageBuilder` lässt den Trailer `ClaudeDo-Task:` weg. Er
|
|
wird nirgends gelesen, und die Konvention verbietet Signatur-Trailer.
|
|
|
|
**Rerun nach Änderungswunsch:** Ein normaler Reject mit Feedback, der auf demselben Worktree
|
|
weiterläuft. Der nächste Approve pusht nach, und der vorhandene PR aktualisiert sich.
|
|
|
|
**Fehler:** Fehlt der PAT, wird der Push abgelehnt oder scheitert ein REST-Aufruf, dann landen
|
|
Statusmeldung und Footer-Strip (`FlashFooterError`) beim Nutzer, und der Task bleibt
|
|
`WaitingForReview`. Scheitert der PR nach einem erfolgreichen Push, sagt die Meldung das genau
|
|
so: „gepusht, PR fehlgeschlagen: …“.
|
|
|
|
**Laufzeit:** Verify plus Push können länger als 5 s dauern. Der MCP-Pfad meldet deshalb Progress
|
|
über `ProgressReporter`, sonst bricht der MCP-Client nach 300 s Leerlauf ab.
|
|
|
|
## 4. Merge-Helper
|
|
|
|
Die Prompts in `PromptFiles` (merge und merge_final) bekommen einen Firmen-Repo-Absatz:
|
|
- `review_task approve` liefert `pr_created`/`pr_updated` mit URL.
|
|
- `verify_merges` entfällt, weil das Verify bereits im Approve läuft.
|
|
- Die Abschluss-Zusammenfassung listet die PR-Links auf.
|
|
- Die Regel „erst mergen, dann neu starten“ bleibt harmlos, da `main` sich nicht bewegt.
|
|
|
|
## 5. UI
|
|
|
|
- Der Settings-Reiter „Ticketsystem“ heißt künftig **„Externe Dienste“**. Er bekommt einen
|
|
zweiten Abschnitt **Azure DevOps** mit PAT-Feld, Test-Button und Löschen-Button, gespiegelt vom
|
|
Ticket-PAT.
|
|
- Neue Hub-Methoden: `GetAzureDevOpsSettings`, `SetAzureDevOpsToken`, `ClearAzureDevOpsToken`,
|
|
`TestAzureDevOpsConnection`. Neuer DI-Marker `AzureDevOpsPatStore`.
|
|
- Der Test braucht ein Firmen-Repo als Kontext: Er nimmt die erste Liste, deren `origin` passt,
|
|
und ruft `GET …/repositories/<repo>` auf.
|
|
- Im Detail-Pane heißt der Approve-Button im Firmen-Repo **„Draft-PR erstellen“**. Er ist weiter
|
|
hinter „Diff öffnen“ gegated, und das Ziel-Dropdown ist ausgeblendet. Ist eine PR-URL gesetzt,
|
|
erscheint ein Link und der Button heißt „PR aktualisieren“.
|
|
- Locale-Keys kommen in `en.json` und `de.json` gleichzeitig hinzu.
|
|
|
|
## 6. Bewusst draußen
|
|
|
|
- PR-Status-Polling bzw. automatisches `Done` nach dem ADO-Merge.
|
|
- Andere PR-Provider als Azure DevOps. `company_remote_pattern` schaltet nur das Schema um, die
|
|
PR-API ist ADO-spezifisch.
|
|
- Aufräumen von Worktree und lokalem Branch, wenn ein Firmen-Task auf `Done` gesetzt wird. Das
|
|
geht über die bestehende Worktree-Übersicht.
|
|
- `git fetch` in privaten Repos. Die Basis bleibt dort lokal.
|
|
- Eine UI für `company_remote_pattern`.
|
|
|
|
## 7. Tests
|
|
|
|
Keine Tests mit der echten Claude-CLI und keine gegen das echte ADO.
|
|
|
|
- **Naming:** Typ-Mapping, Nummernregel (Ticket, privat, Firma ohne Ticket), Umlaute,
|
|
Kollision mit einem fremden Branch (der Branch überlebt, das Suffix greift), Self-Heal löscht
|
|
nur DB-eigene Branches. Die betroffenen Asserts in `WorktreeManagerTests` (`:69`, `:131/138`,
|
|
`:160`, `:314`) passe ich an.
|
|
- **Unit-Branch** (echtes git, Worker.Tests):
|
|
- Ein Child startet vom Head des Unit-Branches.
|
|
- Zwei serielle Children werden per fast-forward absorbiert, und Child 2 sieht die Dateien von
|
|
Child 1.
|
|
- Ein nicht fast-forwardbares Child wird `Failed`.
|
|
- Ein Planning-Parent bekommt seinen Worktree bei `QueuePlanAsync`.
|
|
- **Approve Firma:**
|
|
- Ein lokales Bare-Repo dient als `origin`, und das Muster wird auf dessen Pfad gesetzt.
|
|
- `AzureDevOpsClient` wird gefakt: PR anlegen, vorhandenen PR wiederverwenden, Verify rot
|
|
heißt kein Push, PR-Fehler nach dem Push.
|
|
- **Remote-Parsing:** die Fälle aus dem `-SelfTest` des Skripts.
|
|
- **Basis:**
|
|
- `base_branch` gesetzt: Der Unit-Root startet davon, auch wenn ein anderer Branch ausgecheckt
|
|
ist.
|
|
- Firma: Nach dem Fetch startet der Unit-Root von `origin/<basis>`. Das testet ein Bare-Repo,
|
|
das einen Commit voraus ist.
|
|
- Der lokale Basis-Branch wird nachgezogen, ein schmutziger Checkout bleibt unangetastet.
|
|
- Ein Fetch-Fehler bricht den Task nicht.
|
|
- `SetConfigAsync`, `set_list_config` und `UpdateListConfig` erhalten `base_branch`.
|
|
- **Manueller Smoke-Test (Mika):** PAT eintragen, Test-Button. Ein Ticket-Task in einem echten
|
|
Firmen-Repo bis zum Draft-PR durchspielen, danach eine Planning-Unit mit 2 Children.
|
|
|