From 2b2f61d7fc66c6f3931b794b81199d08d7bf8a42 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 1 Oct 2026 11:48:11 +0200 Subject: [PATCH 01/12] 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. From bc0aa28afbbf3c8bd9577749d8a73614ed28dd66 Mon Sep 17 00:00:00 2001 From: mika kuns <mika.kuns@fb-tuning.de> Date: Thu, 1 Oct 2026 11:49:30 +0200 Subject: [PATCH 02/12] feat(data): worktrees.pull_request_url for the company-repo draft PR --- .../WorktreeEntityConfiguration.cs | 1 + ...4915_AddWorktreePullRequestUrl.Designer.cs | 1172 +++++++++++++++++ ...0261001094915_AddWorktreePullRequestUrl.cs | 28 + .../ClaudeDoDbContextModelSnapshot.cs | 4 + src/ClaudeDo.Data/Models/WorktreeEntity.cs | 2 + .../Repositories/WorktreeRepository.cs | 8 + 6 files changed, 1215 insertions(+) create mode 100644 src/ClaudeDo.Data/Migrations/20261001094915_AddWorktreePullRequestUrl.Designer.cs create mode 100644 src/ClaudeDo.Data/Migrations/20261001094915_AddWorktreePullRequestUrl.cs diff --git a/src/ClaudeDo.Data/Configuration/WorktreeEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/WorktreeEntityConfiguration.cs index 3b1b2194..c361ac1b 100644 --- a/src/ClaudeDo.Data/Configuration/WorktreeEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/WorktreeEntityConfiguration.cs @@ -36,6 +36,7 @@ public class WorktreeEntityConfiguration : IEntityTypeConfiguration<WorktreeEnti builder.Property(w => w.HeadCommit).HasColumnName("head_commit"); builder.Property(w => w.DiffStat).HasColumnName("diff_stat"); builder.Property(w => w.MergeCommit).HasColumnName("merge_commit"); + builder.Property(w => w.PullRequestUrl).HasColumnName("pull_request_url"); builder.Property(w => w.State).HasColumnName("state").IsRequired() .HasDefaultValue(WorktreeState.Active) .HasConversion(StateConverter); diff --git a/src/ClaudeDo.Data/Migrations/20261001094915_AddWorktreePullRequestUrl.Designer.cs b/src/ClaudeDo.Data/Migrations/20261001094915_AddWorktreePullRequestUrl.Designer.cs new file mode 100644 index 00000000..03f2a923 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20261001094915_AddWorktreePullRequestUrl.Designer.cs @@ -0,0 +1,1172 @@ +// <auto-generated /> +using System; +using ClaudeDo.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + [DbContext(typeof(ClaudeDoDbContext))] + [Migration("20261001094915_AddWorktreePullRequestUrl")] + partial class AddWorktreePullRequestUrl + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.11"); + + modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b => + { + b.Property<int>("Id") + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property<bool>("AutoContinueOnUsageLimit") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("auto_continue_on_usage_limit"); + + b.Property<string>("CentralWorktreeRoot") + .HasColumnType("TEXT") + .HasColumnName("central_worktree_root"); + + b.Property<int>("DailyPrepMaxTasks") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5) + .HasColumnName("daily_prep_max_tasks"); + + b.Property<string>("DefaultClaudeInstructions") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("") + .HasColumnName("default_claude_instructions"); + + b.Property<int>("DefaultMaxTurns") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(40) + .HasColumnName("default_max_turns"); + + b.Property<string>("DefaultModel") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sonnet") + .HasColumnName("default_model"); + + b.Property<string>("DefaultPermissionMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("bypassPermissions") + .HasColumnName("default_permission_mode"); + + b.Property<int>("ListHandlerMaxHandoffs") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4) + .HasColumnName("list_handler_max_handoffs"); + + b.Property<int>("MaxParallelExecutions") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1) + .HasColumnName("max_parallel_executions"); + + b.Property<int>("MaxTurnsCeiling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(80) + .HasColumnName("max_turns_ceiling"); + + b.Property<string>("ModelPresets") + .HasColumnType("TEXT") + .HasColumnName("model_presets"); + + b.Property<int>("NextTaskNumber") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1) + .HasColumnName("next_task_number"); + + b.Property<bool>("PowerUseEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("power_use_enabled"); + + b.Property<int>("PowerUseHandoffPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(95) + .HasColumnName("power_use_handoff_pct"); + + b.Property<string>("PowerUseWindowEnd") + .HasColumnType("TEXT") + .HasColumnName("power_use_window_end"); + + b.Property<string>("PowerUseWindowStart") + .HasColumnType("TEXT") + .HasColumnName("power_use_window_start"); + + b.Property<string>("RepoImportFolders") + .HasColumnType("TEXT") + .HasColumnName("repo_import_folders"); + + b.Property<string>("ReportExcludedPaths") + .HasColumnType("TEXT") + .HasColumnName("report_excluded_paths"); + + b.Property<string>("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property<int>("StandupWeekday") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(3) + .HasColumnName("standup_weekday"); + + b.Property<string>("TicketFallbackListId") + .HasColumnType("TEXT") + .HasColumnName("ticket_fallback_list_id"); + + b.Property<int>("UsageGateFiveHourPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(80) + .HasColumnName("usage_gate_five_hour_pct"); + + b.Property<int>("UsageGateSevenDayPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(90) + .HasColumnName("usage_gate_seven_day_pct"); + + b.Property<int>("UsageThrottleFiveHourHardPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(65) + .HasColumnName("usage_throttle_five_hour_hard_pct"); + + b.Property<int>("UsageThrottleFiveHourSoftPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(50) + .HasColumnName("usage_throttle_five_hour_soft_pct"); + + b.Property<int>("UsageThrottleSevenDayHardPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(65) + .HasColumnName("usage_throttle_seven_day_hard_pct"); + + b.Property<int>("UsageThrottleSevenDaySoftPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(50) + .HasColumnName("usage_throttle_seven_day_soft_pct"); + + b.Property<int>("WorktreeAutoCleanupDays") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(7) + .HasColumnName("worktree_auto_cleanup_days"); + + b.Property<bool>("WorktreeAutoCleanupEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("worktree_auto_cleanup_enabled"); + + b.Property<string>("WorktreeStrategy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sibling") + .HasColumnName("worktree_strategy"); + + b.HasKey("Id"); + + b.ToTable("app_settings", (string)null); + + b.HasData( + new + { + Id = 1, + AutoContinueOnUsageLimit = false, + DailyPrepMaxTasks = 5, + DefaultClaudeInstructions = "", + DefaultMaxTurns = 40, + DefaultModel = "sonnet", + DefaultPermissionMode = "auto", + ListHandlerMaxHandoffs = 4, + MaxParallelExecutions = 1, + MaxTurnsCeiling = 80, + NextTaskNumber = 1, + PowerUseEnabled = false, + PowerUseHandoffPct = 95, + StandupWeekday = 3, + UsageGateFiveHourPct = 80, + UsageGateSevenDayPct = 90, + UsageThrottleFiveHourHardPct = 65, + UsageThrottleFiveHourSoftPct = 50, + UsageThrottleSevenDayHardPct = 65, + UsageThrottleSevenDaySoftPct = 50, + WorktreeAutoCleanupDays = 7, + WorktreeAutoCleanupEnabled = false, + WorktreeStrategy = "sibling" + }); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b => + { + b.Property<string>("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property<DateOnly>("Date") + .HasColumnType("TEXT") + .HasColumnName("note_date"); + + b.Property<bool>("FromAgent") + .HasColumnType("INTEGER") + .HasColumnName("from_agent"); + + b.Property<int>("SortOrder") + .HasColumnType("INTEGER") + .HasColumnName("sort_order"); + + b.Property<string>("Text") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("text"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.ToTable("daily_notes", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.Property<string>("ListId") + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property<string>("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property<string>("BaseBranch") + .HasColumnType("TEXT") + .HasColumnName("base_branch"); + + b.Property<int?>("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property<string>("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property<string>("PermissionMode") + .HasColumnType("TEXT") + .HasColumnName("permission_mode"); + + b.Property<bool>("PowerUseAllowed") + .HasColumnType("INTEGER") + .HasColumnName("power_use_allowed"); + + b.Property<string>("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property<string>("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property<int?>("TicketProjectId") + .HasColumnType("INTEGER") + .HasColumnName("ticket_project_id"); + + b.Property<string>("VerifyCommand") + .HasColumnType("TEXT") + .HasColumnName("verify_command"); + + b.HasKey("ListId"); + + b.ToTable("list_config", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Property<string>("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property<string>("Color") + .HasColumnType("TEXT") + .HasColumnName("color"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property<string>("DefaultCommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("default_commit_type"); + + b.Property<bool>("FindingsTracked") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("findings_tracked"); + + b.Property<string>("FolderId") + .HasColumnType("TEXT") + .HasColumnName("folder_id"); + + b.Property<string>("Icon") + .HasColumnType("TEXT") + .HasColumnName("icon"); + + b.Property<bool>("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property<int>("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property<string>("WorkingDir") + .HasColumnType("TEXT") + .HasColumnName("working_dir"); + + b.HasKey("Id"); + + b.HasIndex("FolderId") + .HasDatabaseName("idx_lists_folder"); + + b.HasIndex("SortOrder") + .HasDatabaseName("idx_lists_sort"); + + b.ToTable("lists", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListFolderEntity", b => + { + b.Property<string>("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property<string>("Color") + .HasColumnType("TEXT") + .HasColumnName("color"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property<string>("Icon") + .HasColumnType("TEXT") + .HasColumnName("icon"); + + b.Property<bool>("IsCollapsed") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_collapsed"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property<string>("ParentFolderId") + .HasColumnType("TEXT") + .HasColumnName("parent_folder_id"); + + b.Property<int>("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("ParentFolderId") + .HasDatabaseName("idx_list_folders_parent"); + + b.HasIndex("SortOrder") + .HasDatabaseName("idx_list_folders_sort"); + + b.ToTable("list_folders", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.MissionControlSessionEntity", b => + { + b.Property<string>("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property<string>("BriefPath") + .HasColumnType("TEXT") + .HasColumnName("brief_path"); + + b.Property<string>("Cwd") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cwd"); + + b.Property<string>("PredecessorSessionId") + .HasColumnType("TEXT") + .HasColumnName("predecessor_session_id"); + + b.Property<DateTime>("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property<string>("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property<string>("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("SessionId"); + + b.HasIndex("StartedAt"); + + b.ToTable("mission_control_sessions", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b => + { + b.Property<Guid>("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property<int>("Days") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(31) + .HasColumnName("days_of_week"); + + b.Property<bool>("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true) + .HasColumnName("enabled"); + + b.Property<string>("Kind") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("ping") + .HasColumnName("action_kind"); + + b.Property<DateTimeOffset?>("LastRunAt") + .HasColumnType("TEXT") + .HasColumnName("last_run_at"); + + b.Property<string>("PromptOverride") + .HasColumnType("TEXT") + .HasColumnName("prompt_override"); + + b.Property<TimeSpan>("TimeOfDay") + .HasColumnType("TEXT") + .HasColumnName("time_of_day"); + + b.HasKey("Id"); + + b.ToTable("prime_schedules", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b => + { + b.Property<string>("Name") + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property<DateTimeOffset>("AddedAt") + .HasColumnType("TEXT") + .HasColumnName("added_at"); + + b.Property<string>("Description") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property<string>("PinnedRef") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("pinned_ref"); + + b.Property<string>("SourceUrl") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("source_url"); + + b.Property<string>("Subpath") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("subpath"); + + b.HasKey("Name"); + + b.ToTable("session_skills", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.Property<string>("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property<bool>("Completed") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("completed"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property<int>("OrderNum") + .HasColumnType("INTEGER") + .HasColumnName("order_num"); + + b.Property<string>("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property<string>("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_subtasks_task_id"); + + b.ToTable("subtasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b => + { + b.Property<string>("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property<long>("ByteSize") + .HasColumnType("INTEGER") + .HasColumnName("byte_size"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property<string>("FileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("file_name"); + + b.Property<string>("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_attachments_task_id"); + + b.ToTable("task_attachments", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskDependencyEntity", b => + { + b.Property<string>("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property<string>("DependsOnTaskId") + .HasColumnType("TEXT") + .HasColumnName("depends_on_task_id"); + + b.HasKey("TaskId", "DependsOnTaskId"); + + b.HasIndex("DependsOnTaskId") + .HasDatabaseName("idx_task_dependencies_depends_on"); + + b.ToTable("task_dependencies", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Property<string>("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property<string>("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property<string>("BlockedByTaskId") + .HasColumnType("TEXT") + .HasColumnName("blocked_by_task_id"); + + b.Property<string>("CommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("commit_type"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property<string>("CreatedBy") + .HasColumnType("TEXT") + .HasColumnName("created_by"); + + b.Property<string>("Description") + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property<bool>("ExpectsNoDiff") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("expects_no_diff"); + + b.Property<int?>("FailureMaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("failure_max_turns"); + + b.Property<string>("FailureReason") + .HasColumnType("TEXT") + .HasColumnName("failure_reason"); + + b.Property<int?>("FailureTurnsUsed") + .HasColumnType("INTEGER") + .HasColumnName("failure_turns_used"); + + b.Property<DateTime?>("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property<string>("HandlerBaseCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_base_commit"); + + b.Property<string>("HandlerHeadCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_head_commit"); + + b.Property<string>("InteractiveSessionId") + .HasColumnType("TEXT") + .HasColumnName("interactive_session_id"); + + b.Property<bool>("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property<bool>("IsMyDay") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_my_day"); + + b.Property<bool>("IsStarred") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_starred"); + + b.Property<string>("ListId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property<string>("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property<int?>("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property<string>("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property<string>("Notes") + .HasColumnType("TEXT") + .HasColumnName("notes"); + + b.Property<int>("Number") + .HasColumnType("INTEGER") + .HasColumnName("number"); + + b.Property<string>("ParentTaskId") + .HasColumnType("TEXT") + .HasColumnName("parent_task_id"); + + b.Property<string>("PermissionMode") + .HasColumnType("TEXT") + .HasColumnName("permission_mode"); + + b.Property<DateTime?>("PlanningFinalizedAt") + .HasColumnType("TEXT") + .HasColumnName("planning_finalized_at"); + + b.Property<string>("PlanningPhase") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("none") + .HasColumnName("planning_phase"); + + b.Property<string>("PlanningSessionId") + .HasColumnType("TEXT") + .HasColumnName("planning_session_id"); + + b.Property<string>("PlanningSessionToken") + .HasColumnType("TEXT") + .HasColumnName("planning_session_token"); + + b.Property<string>("Result") + .HasColumnType("TEXT") + .HasColumnName("result"); + + b.Property<string>("ReviewFeedback") + .HasColumnType("TEXT") + .HasColumnName("review_feedback"); + + b.Property<int>("RoadblockCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("roadblock_count"); + + b.Property<DateTime?>("ScheduledFor") + .HasColumnType("TEXT") + .HasColumnName("scheduled_for"); + + b.Property<string>("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property<int>("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property<DateTime?>("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property<string>("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property<string>("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property<string>("TicketRef") + .HasColumnType("TEXT") + .HasColumnName("ticket_ref"); + + b.Property<string>("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.Property<DateTime?>("UsageLimitAutoContinuedAt") + .HasColumnType("TEXT") + .HasColumnName("usage_limit_auto_continued_at"); + + b.HasKey("Id"); + + b.HasIndex("BlockedByTaskId") + .HasDatabaseName("idx_tasks_blocked_by"); + + b.HasIndex("ListId") + .HasDatabaseName("idx_tasks_list_id"); + + b.HasIndex("Number") + .IsUnique() + .HasDatabaseName("idx_tasks_number"); + + b.HasIndex("ParentTaskId") + .HasDatabaseName("idx_tasks_parent_task_id"); + + b.HasIndex("Status") + .HasDatabaseName("idx_tasks_status"); + + b.HasIndex("ListId", "SortOrder") + .HasDatabaseName("idx_tasks_list_sort"); + + b.ToTable("tasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.Property<string>("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property<int?>("CacheReadTokens") + .HasColumnType("INTEGER") + .HasColumnName("cache_read_tokens"); + + b.Property<int?>("CacheWriteTokens") + .HasColumnType("INTEGER") + .HasColumnName("cache_write_tokens"); + + b.Property<string>("ErrorMarkdown") + .HasColumnType("TEXT") + .HasColumnName("error_markdown"); + + b.Property<string>("Errors") + .HasColumnType("TEXT") + .HasColumnName("errors"); + + b.Property<int?>("ExitCode") + .HasColumnType("INTEGER") + .HasColumnName("exit_code"); + + b.Property<DateTime?>("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property<bool>("IsRetry") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_retry"); + + b.Property<string>("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property<string>("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property<string>("Prompt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("prompt"); + + b.Property<string>("ResultMarkdown") + .HasColumnType("TEXT") + .HasColumnName("result_markdown"); + + b.Property<string>("ResultSubtype") + .HasColumnType("TEXT") + .HasColumnName("result_subtype"); + + b.Property<int>("RunNumber") + .HasColumnType("INTEGER") + .HasColumnName("run_number"); + + b.Property<string>("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property<DateTime?>("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property<string>("StructuredOutputJson") + .HasColumnType("TEXT") + .HasColumnName("structured_output"); + + b.Property<string>("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property<string>("TerminalReason") + .HasColumnType("TEXT") + .HasColumnName("terminal_reason"); + + b.Property<int?>("TokensIn") + .HasColumnType("INTEGER") + .HasColumnName("tokens_in"); + + b.Property<int?>("TokensOut") + .HasColumnType("INTEGER") + .HasColumnName("tokens_out"); + + b.Property<int?>("TurnCount") + .HasColumnType("INTEGER") + .HasColumnName("turn_count"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_runs_task_id"); + + b.ToTable("task_runs", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b => + { + b.Property<string>("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property<DateOnly>("EndDate") + .HasColumnType("TEXT") + .HasColumnName("end_date"); + + b.Property<DateTime>("GeneratedAt") + .HasColumnType("TEXT") + .HasColumnName("generated_at"); + + b.Property<string>("Markdown") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("markdown"); + + b.Property<DateOnly>("StartDate") + .HasColumnType("TEXT") + .HasColumnName("start_date"); + + b.HasKey("Id"); + + b.HasIndex("StartDate", "EndDate") + .IsUnique(); + + b.ToTable("week_reports", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.Property<string>("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property<string>("BaseCommit") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("base_commit"); + + b.Property<string>("BranchName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("branch_name"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property<string>("DiffStat") + .HasColumnType("TEXT") + .HasColumnName("diff_stat"); + + b.Property<string>("HeadCommit") + .HasColumnType("TEXT") + .HasColumnName("head_commit"); + + b.Property<string>("MergeCommit") + .HasColumnType("TEXT") + .HasColumnName("merge_commit"); + + b.Property<string>("Path") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property<string>("PullRequestUrl") + .HasColumnType("TEXT") + .HasColumnName("pull_request_url"); + + b.Property<string>("State") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("active") + .HasColumnName("state"); + + b.HasKey("TaskId"); + + b.ToTable("worktrees", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") + .WithOne("Config") + .HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("List"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.ListFolderEntity", null) + .WithMany() + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListFolderEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.ListFolderEntity", null) + .WithMany() + .HasForeignKey("ParentFolderId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany("Subtasks") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskDependencyEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) + .WithMany() + .HasForeignKey("DependsOnTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) + .WithMany("Dependencies") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) + .WithMany() + .HasForeignKey("BlockedByTaskId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") + .WithMany("Tasks") + .HasForeignKey("ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent") + .WithMany("Children") + .HasForeignKey("ParentTaskId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("List"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany("Runs") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithOne("Worktree") + .HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Navigation("Config"); + + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Navigation("Children"); + + b.Navigation("Dependencies"); + + b.Navigation("Runs"); + + b.Navigation("Subtasks"); + + b.Navigation("Worktree"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/20261001094915_AddWorktreePullRequestUrl.cs b/src/ClaudeDo.Data/Migrations/20261001094915_AddWorktreePullRequestUrl.cs new file mode 100644 index 00000000..9aa657eb --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20261001094915_AddWorktreePullRequestUrl.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// <inheritdoc /> + public partial class AddWorktreePullRequestUrl : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn<string>( + name: "pull_request_url", + table: "worktrees", + type: "TEXT", + nullable: true); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "pull_request_url", + table: "worktrees"); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index b3b1353d..10197d0d 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -1019,6 +1019,10 @@ namespace ClaudeDo.Data.Migrations .HasColumnType("TEXT") .HasColumnName("path"); + b.Property<string>("PullRequestUrl") + .HasColumnType("TEXT") + .HasColumnName("pull_request_url"); + b.Property<string>("State") .IsRequired() .ValueGeneratedOnAdd() diff --git a/src/ClaudeDo.Data/Models/WorktreeEntity.cs b/src/ClaudeDo.Data/Models/WorktreeEntity.cs index defcb4a9..4cffe7ae 100644 --- a/src/ClaudeDo.Data/Models/WorktreeEntity.cs +++ b/src/ClaudeDo.Data/Models/WorktreeEntity.cs @@ -17,6 +17,8 @@ public sealed class WorktreeEntity public string? HeadCommit { get; set; } public string? DiffStat { get; set; } public string? MergeCommit { get; set; } + /// <summary>Draft-PR auf Azure DevOps, gesetzt vom Approve im Firmen-Repo.</summary> + public string? PullRequestUrl { get; set; } public WorktreeState State { get; set; } = WorktreeState.Active; public required DateTime CreatedAt { get; init; } diff --git a/src/ClaudeDo.Data/Repositories/WorktreeRepository.cs b/src/ClaudeDo.Data/Repositories/WorktreeRepository.cs index af6d4eef..5064820d 100644 --- a/src/ClaudeDo.Data/Repositories/WorktreeRepository.cs +++ b/src/ClaudeDo.Data/Repositories/WorktreeRepository.cs @@ -47,6 +47,14 @@ public sealed class WorktreeRepository /// <summary>Records a successful auto-rebase: the branch's fork point moves to the rebased-onto /// commit and its head moves to the new tip <c>git rebase</c> produced.</summary> + /// <summary>Stores the draft PR an approve in a company repo created or reused.</summary> + public async Task SetPullRequestUrlAsync(string taskId, string url, CancellationToken ct = default) + { + await _context.Worktrees + .Where(w => w.TaskId == taskId) + .ExecuteUpdateAsync(s => s.SetProperty(w => w.PullRequestUrl, url), ct); + } + public async Task SetRebasedAsync(string taskId, string baseCommit, string headCommit, CancellationToken ct = default) { await _context.Worktrees From 76f260a9a5c4e8cad1eac03c58af70d8dce4b90a Mon Sep 17 00:00:00 2001 From: mika kuns <mika.kuns@fb-tuning.de> Date: Thu, 1 Oct 2026 11:51:28 +0200 Subject: [PATCH 03/12] feat(git): fetch/push/ff primitives with auth env, no task trailer in company repos --- src/ClaudeDo.Data/Git/GitService.cs | 44 ++++++++++++++++++- .../Runner/CommitMessageBuilder.cs | 12 +++-- src/ClaudeDo.Worker/Runner/WorktreeManager.cs | 4 +- .../Runner/CommitMessageBuilderTests.cs | 9 ++++ 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/ClaudeDo.Data/Git/GitService.cs b/src/ClaudeDo.Data/Git/GitService.cs index 13cd34a4..8e950085 100644 --- a/src/ClaudeDo.Data/Git/GitService.cs +++ b/src/ClaudeDo.Data/Git/GitService.cs @@ -450,6 +450,45 @@ public sealed class GitService return (exitCode, stderr); } + /// <summary> + /// <c>git fetch origin</c> for one branch into <c>refs/remotes/origin/<branch></c>. The explicit + /// refspec works without a configured fetch refspec. <paramref name="env"/> carries the auth + /// header (never the command line) and must disable the terminal prompt. + /// </summary> + public async Task<(int ExitCode, string Stderr)> FetchBranchAsync( + string repoDir, string branch, IReadOnlyDictionary<string, string>? env, CancellationToken ct = default) + { + var (exitCode, _, stderr) = await RunGitAsync(repoDir, + ["fetch", "origin", $"+refs/heads/{branch}:refs/remotes/origin/{branch}"], ct, env: env); + return (exitCode, stderr); + } + + /// <summary>Moves the local, not checked-out <paramref name="branch"/> to <c>origin/<branch></c>; git itself refuses a non-fast-forward. No network.</summary> + public async Task<(int ExitCode, string Stderr)> FastForwardLocalBranchAsync( + string repoDir, string branch, CancellationToken ct = default) + { + var (exitCode, _, stderr) = await RunGitAsync(repoDir, + ["fetch", ".", $"refs/remotes/origin/{branch}:refs/heads/{branch}"], ct); + return (exitCode, stderr); + } + + /// <summary><c>git push -u origin <branch></c> from <paramref name="worktreePath"/>; auth via <paramref name="env"/>.</summary> + public async Task<(int ExitCode, string Stderr)> PushBranchAsync( + string worktreePath, string branch, IReadOnlyDictionary<string, string>? env, CancellationToken ct = default) + { + var (exitCode, _, stderr) = await RunGitAsync(worktreePath, ["push", "-u", "origin", branch], ct, env: env); + return (exitCode, stderr); + } + + /// <summary>Branch that <c>refs/remotes/origin/HEAD</c> points to (without <c>origin/</c>), or null.</summary> + public async Task<string?> GetRemoteHeadBranchAsync(string repoDir, CancellationToken ct = default) + { + var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], ct); + if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout)) return null; + var head = stdout.Trim(); + return head.StartsWith("origin/", StringComparison.Ordinal) ? head["origin/".Length..] : head; + } + /// <summary><c>git merge --ff-only</c>: moves the checked-out branch to <paramref name="sourceRef"/> or fails without touching it.</summary> public async Task<(int ExitCode, string Stderr)> MergeFastForwardOnlyAsync( string worktreePath, string sourceRef, CancellationToken ct = default) @@ -721,7 +760,8 @@ public sealed class GitService private static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitAsync( - string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null, bool trimOutput = true) + string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null, bool trimOutput = true, + IReadOnlyDictionary<string, string>? env = null) { var psi = new ProcessStartInfo { @@ -742,6 +782,8 @@ public sealed class GitService // of the OS locale git for Windows picks up. psi.Environment["LC_ALL"] = "C"; psi.Environment["LANGUAGE"] = "C"; + if (env is not null) + foreach (var (key, value) in env) psi.Environment[key] = value; psi.ArgumentList.Add("-C"); psi.ArgumentList.Add(workDir); foreach (var a in args) psi.ArgumentList.Add(a); diff --git a/src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs b/src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs index ab2b7dba..1365f940 100644 --- a/src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs +++ b/src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs @@ -6,7 +6,9 @@ namespace ClaudeDo.Worker.Runner; public static class CommitMessageBuilder { - public static string Build(string commitType, string listName, string taskTitle, string? taskDescription, string taskId) + /// <summary>Task commit message; <paramref name="includeTrailer"/> is false in company repos, whose convention forbids signature trailers.</summary> + public static string Build(string commitType, string listName, string taskTitle, string? taskDescription, string taskId, + bool includeTrailer = true) { var slug = ToSlug(listName); var title = Truncate(taskTitle, 60); @@ -22,9 +24,11 @@ public static class CommitMessageBuilder sb.Append(Truncate(taskDescription!.Trim(), 400)); } - // Trailer is always included. - sb.Append("\n\n"); - sb.Append($"ClaudeDo-Task: {taskId}"); + if (includeTrailer) + { + sb.Append("\n\n"); + sb.Append($"ClaudeDo-Task: {taskId}"); + } return sb.ToString(); } diff --git a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs index 420e1c25..6aa3fe61 100644 --- a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs +++ b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs @@ -199,8 +199,10 @@ public sealed class WorktreeManager await _git.AddAllAsync(ctx.WorktreePath, ct); + var isCompany = list.WorkingDir is not null + && await CompanyRepo.IsCompanyAsync(_git, list.WorkingDir, _cfg.CompanyRemotePattern, ct); var message = CommitMessageBuilder.Build( - task.CommitType, list.Name, task.Title, task.Description, task.Id); + task.CommitType, list.Name, task.Title, task.Description, task.Id, includeTrailer: !isCompany); await _git.CommitAsync(ctx.WorktreePath, message, ct); diff --git a/tests/ClaudeDo.Worker.Tests/Runner/CommitMessageBuilderTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/CommitMessageBuilderTests.cs index 5984c9b3..879566ef 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/CommitMessageBuilderTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/CommitMessageBuilderTests.cs @@ -71,6 +71,15 @@ public class CommitMessageBuilderTests Assert.Equal("ClaudeDo-Task: id-456", lines[4]); } + [Fact] + public void WithoutTrailer_CompanyRepoCommitHasNoClaudeDoTaskLine() + { + var msg = CommitMessageBuilder.Build("feat", "Lager App", "add scan", "Detailed description here", "id-456", + includeTrailer: false); + + Assert.Equal("feat(lager-app): add scan\n\nDetailed description here", msg); + } + [Fact] public void BuildMerge_ConventionalHeaderWithMergeVerbAndTrailer() { From 78f4d182e89c33b5d7c934f6c248c4b6bf695cb4 Mon Sep 17 00:00:00 2001 From: mika kuns <mika.kuns@fb-tuning.de> Date: Thu, 1 Oct 2026 11:55:11 +0200 Subject: [PATCH 04/12] feat(worker): Azure DevOps client and PAT store, ported from pr-create.ps1 --- .../AzureDevOps/AzureDevOpsClient.cs | 181 ++++++++++++++++++ src/ClaudeDo.Worker/DpapiTokenStore.cs | 5 +- src/ClaudeDo.Worker/Program.cs | 4 + .../AzureDevOps/AzureDevOpsClientTests.cs | 160 ++++++++++++++++ 4 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 src/ClaudeDo.Worker/AzureDevOps/AzureDevOpsClient.cs create mode 100644 tests/ClaudeDo.Worker.Tests/AzureDevOps/AzureDevOpsClientTests.cs diff --git a/src/ClaudeDo.Worker/AzureDevOps/AzureDevOpsClient.cs b/src/ClaudeDo.Worker/AzureDevOps/AzureDevOpsClient.cs new file mode 100644 index 00000000..a852fd2c --- /dev/null +++ b/src/ClaudeDo.Worker/AzureDevOps/AzureDevOpsClient.cs @@ -0,0 +1,181 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace ClaudeDo.Worker.AzureDevOps; + +/// <summary>An Azure DevOps Server repo derived from its remote URL; <see cref="ApiBase"/> is collection + project.</summary> +public sealed record AzdoRepo(string ApiBase, string Repo, string WebUrl); + +/// <summary>A REST or configuration failure, with a message that can go straight to the user.</summary> +public sealed class AzureDevOpsException : Exception +{ + public AzureDevOpsException(string message) : base(message) { } +} + +/// <summary> +/// The one HTTP surface to the on-prem Azure DevOps Server, ported from work-conventions' <c>pr-create.ps1</c>. +/// REST, not the az CLI (cloud-only) and not the pullrequestcreate URL (on-prem ignores title/description). +/// </summary> +public sealed class AzureDevOpsClient +{ + private const string DefaultApiVersion = "6.0"; + // ADO caps a PR description at 4000 characters and rejects anything longer. + private const int MaxDescriptionLength = 4000; + + private readonly IHttpClientFactory _httpFactory; + private readonly AzureDevOpsPatStore _pat; + private string _apiVersion = DefaultApiVersion; + + public AzureDevOpsClient(IHttpClientFactory httpFactory, AzureDevOpsPatStore pat) + { + _httpFactory = httpFactory; + _pat = pat; + } + + /// <summary>The stored PAT, or null.</summary> + public string? Pat => _pat.Store.Read(); + + /// <summary> + /// Splits an ADO remote at <c>/_git/</c>: everything before it already is the API prefix, so collection + /// and project never need parsing (holds for virtual directories like <c>/tfs/</c> too). Embedded + /// credentials are dropped. Null for anything that is not an http(s) ADO remote. + /// </summary> + public static AzdoRepo? ParseRemote(string? remote) + { + if (string.IsNullOrWhiteSpace(remote)) return null; + var clean = Regex.Replace(remote.Trim(), @"\.git$", ""); + clean = Regex.Replace(clean, @"^(https?://)[^/@]+@", "$1"); + var m = Regex.Match(clean, @"^(?<base>https?://.+)/_git/(?<repo>[^/]+)$"); + return m.Success ? new AzdoRepo(m.Groups["base"].Value, Uri.UnescapeDataString(m.Groups["repo"].Value), clean) : null; + } + + /// <summary>Basic auth with an empty user, as ADO expects for a PAT.</summary> + public static string AuthHeaderValue(string pat) => "Basic " + Convert.ToBase64String(Encoding.ASCII.GetBytes(":" + pat)); + + /// <summary> + /// Environment for a git network call: never prompt (a background process would hang), and hand the PAT + /// over as <c>http.extraHeader</c> via <c>GIT_CONFIG_*</c> so it never shows up in a command line. + /// </summary> + public static IReadOnlyDictionary<string, string> GitAuthEnv(string? pat) + { + var env = new Dictionary<string, string> { ["GIT_TERMINAL_PROMPT"] = "0" }; + if (!string.IsNullOrEmpty(pat)) + { + env["GIT_CONFIG_COUNT"] = "1"; + env["GIT_CONFIG_KEY_0"] = "http.extraHeader"; + env["GIT_CONFIG_VALUE_0"] = "Authorization: " + AuthHeaderValue(pat); + } + return env; + } + + /// <summary>The API version an out-of-range error names (<c>… supports is 6.0</c>), else null.</summary> + public static string? SupportedVersion(string? error) + { + if (error is null) return null; + var m = Regex.Match(error, @"supports is (?<v>\d+\.\d+)"); + return m.Success ? m.Groups["v"].Value : null; + } + + /// <summary>The repo's default branch without <c>refs/heads/</c>.</summary> + public async Task<string> GetDefaultBranchAsync(AzdoRepo repo, CancellationToken ct) + { + var json = await SendAsync(repo, HttpMethod.Get, "", null, null, ct); + var branch = json["defaultBranch"]?.GetValue<string>() + ?? throw new AzureDevOpsException($"Azure DevOps repo '{repo.Repo}' has no default branch."); + return StripHeads(branch); + } + + /// <summary> + /// Reuses the active PR for <paramref name="source"/> if there is one (idempotent — the work-conventions + /// skill may already have opened it), else opens a draft PR into <paramref name="target"/>. + /// </summary> + public async Task<(string Url, bool Created)> EnsureDraftPullRequestAsync( + AzdoRepo repo, string source, string target, string title, string description, CancellationToken ct) + { + var sourceRef = "refs/heads/" + source; + var existing = await SendAsync(repo, HttpMethod.Get, "pullrequests", + $"searchCriteria.sourceRefName={Uri.EscapeDataString(sourceRef)}&searchCriteria.status=active&", null, ct); + if (existing["value"] is JsonArray { Count: > 0 } prs && prs[0]?["pullRequestId"]?.GetValue<int>() is { } existingId) + return (PullRequestUrl(repo, existingId), false); + + var body = new JsonObject + { + ["sourceRefName"] = sourceRef, + ["targetRefName"] = "refs/heads/" + target, + ["title"] = title, + ["description"] = description.Length <= MaxDescriptionLength ? description : description[..MaxDescriptionLength], + ["isDraft"] = true, + }; + var created = await SendAsync(repo, HttpMethod.Post, "pullrequests", null, body.ToJsonString(), ct); + var id = created["pullRequestId"]?.GetValue<int>() + ?? throw new AzureDevOpsException("Azure DevOps answered the PR request without a pullRequestId."); + return (PullRequestUrl(repo, id), true); + } + + private static string PullRequestUrl(AzdoRepo repo, int id) => $"{repo.WebUrl}/pullrequest/{id}"; + + private static string StripHeads(string branch) => + branch.StartsWith("refs/heads/", StringComparison.Ordinal) ? branch["refs/heads/".Length..] : branch; + + private async Task<JsonNode> SendAsync( + AzdoRepo repo, HttpMethod method, string path, string? query, string? body, CancellationToken ct) + { + var pat = Pat; + if (string.IsNullOrEmpty(pat)) + throw new AzureDevOpsException("Kein Azure-DevOps-PAT hinterlegt (Einstellungen → Externe Dienste)."); + + var baseUrl = $"{repo.ApiBase}/_apis/git/repositories/{Uri.EscapeDataString(repo.Repo)}"; + if (path.Length > 0) baseUrl += "/" + path; + + // The server names the version it supports in its error; retry once with it instead of making the user configure one. + for (var attempt = 1; ; attempt++) + { + using var request = new HttpRequestMessage(method, $"{baseUrl}?{query}api-version={_apiVersion}"); + request.Headers.Authorization = AuthenticationHeaderValue.Parse(AuthHeaderValue(pat)); + if (body is not null) request.Content = new StringContent(body, Encoding.UTF8, "application/json"); + + HttpResponseMessage response; + try { response = await _httpFactory.CreateClient("azdo").SendAsync(request, ct); } + catch (Exception ex) when (!ct.IsCancellationRequested) + { + throw new AzureDevOpsException($"Azure DevOps nicht erreichbar ({repo.ApiBase}): {ex.Message}"); + } + + using (response) + { + var payload = await response.Content.ReadAsStringAsync(ct); + // A rejected PAT comes back as 203 + an HTML login page, which IsSuccessStatusCode counts as success. + if ((int)response.StatusCode == 203) + throw new AzureDevOpsException("Azure DevOps 203: PAT abgelaufen oder ohne Scope Code (Read & Write)."); + if (response.IsSuccessStatusCode) + { + try { return JsonNode.Parse(payload) ?? new JsonObject(); } + catch (JsonException ex) { throw new AzureDevOpsException($"Unerwartete Antwort von Azure DevOps: {ex.Message}"); } + } + + var detail = ErrorMessage(payload); + var better = SupportedVersion(detail); + if (attempt == 1 && better is not null && better != _apiVersion) + { + _apiVersion = better; + continue; + } + var hint = (int)response.StatusCode == 401 ?" — PAT abgelaufen oder ohne Scope Code (Read & Write)" : ""; + throw new AzureDevOpsException($"Azure DevOps {(int)response.StatusCode} bei {method} {path}{hint}: {detail}"); + } + } + } + + private static string ErrorMessage(string payload) + { + try + { + if (JsonNode.Parse(payload)?["message"]?.GetValue<string>() is { Length: > 0 } msg) return msg; + } + catch (JsonException) { } + return payload.Length <= 500 ? payload : payload[..500]; + } +} diff --git a/src/ClaudeDo.Worker/DpapiTokenStore.cs b/src/ClaudeDo.Worker/DpapiTokenStore.cs index 2d732316..78696272 100644 --- a/src/ClaudeDo.Worker/DpapiTokenStore.cs +++ b/src/ClaudeDo.Worker/DpapiTokenStore.cs @@ -8,7 +8,7 @@ namespace ClaudeDo.Worker; /// <summary> /// Persists a single secret encrypted with DPAPI (CurrentUser scope), one instance per file. /// Windows-only. Used for the Online-Inbox refresh token (~/.claudeDo/online-inbox.token) and -/// the ticket-system PAT (~/.claudeDo/ticket.pat). +/// the ticket-system PAT (~/.claudeDo/ticket.pat) and the Azure DevOps PAT (~/.claudeDo/azdo.pat). /// </summary> [SupportedOSPlatform("windows")] public sealed class DpapiTokenStore @@ -55,3 +55,6 @@ public sealed record OnlineRefreshTokenStore(DpapiTokenStore Store); /// DI-Marker: der Personal Access Token des Ticketsystems. public sealed record TicketPatStore(DpapiTokenStore Store); + +/// DI-Marker: der Personal Access Token für Azure DevOps (Push + Draft-PR im Firmen-Repo). +public sealed record AzureDevOpsPatStore(DpapiTokenStore Store); diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 74ee597a..ef037935 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -4,6 +4,7 @@ using ClaudeDo.Data.Git; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker; using ClaudeDo.Worker.Agents; +using ClaudeDo.Worker.AzureDevOps; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.External; using ClaudeDo.Worker.Findings; @@ -247,12 +248,15 @@ builder.Services.AddSingleton(cfg.OnlineInbox); #pragma warning disable CA1416 // ClaudeDo.Worker is Windows-only; DPAPI is fine here. builder.Services.AddSingleton(new OnlineRefreshTokenStore(DpapiTokenStore.InAppData("online-inbox.token"))); builder.Services.AddSingleton(new TicketPatStore(DpapiTokenStore.InAppData("ticket.pat"))); +builder.Services.AddSingleton(new AzureDevOpsPatStore(DpapiTokenStore.InAppData("azdo.pat"))); #pragma warning restore CA1416 builder.Services.AddSingleton<TicketSystemConfig>(); builder.Services.AddHttpClient("tickets"); builder.Services.AddSingleton<TicketClientFactory>(); builder.Services.AddSingleton<TicketStatusSync>(); builder.Services.AddSingleton<TicketImportService>(); +builder.Services.AddHttpClient("azdo", c => c.Timeout = TimeSpan.FromSeconds(30)); +builder.Services.AddSingleton<AzureDevOpsClient>(); if (cfg.OnlineInbox.Enabled) { diff --git a/tests/ClaudeDo.Worker.Tests/AzureDevOps/AzureDevOpsClientTests.cs b/tests/ClaudeDo.Worker.Tests/AzureDevOps/AzureDevOpsClientTests.cs new file mode 100644 index 00000000..4956d1b7 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/AzureDevOps/AzureDevOpsClientTests.cs @@ -0,0 +1,160 @@ +using System.Net; +using System.Text.Json.Nodes; +using ClaudeDo.Worker.AzureDevOps; +using ClaudeDo.Worker.Tests.Tickets; + +namespace ClaudeDo.Worker.Tests.AzureDevOps; + +/// <summary>Port of pr-create.ps1: its -SelfTest cases plus the PR find-or-create flow, HTTP stubbed.</summary> +public sealed class AzureDevOpsClientTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), $"cd_azdo_{Guid.NewGuid():N}"); + + public void Dispose() + { + try { Directory.Delete(_dir, true); } catch { } + } + + internal static AzureDevOpsPatStore PatStore(string dir, string? pat = "geheim") + { + var store = new AzureDevOpsPatStore(new DpapiTokenStore(Path.Combine(dir, "azdo.pat"))); + if (pat is not null) store.Store.Save(pat); + return store; + } + + private AzureDevOpsClient Client(StubHandler handler, string? pat = "geheim") => + new(new StubHttpClientFactory(handler), PatStore(_dir, pat)); + + private static readonly AzdoRepo Repo = new("https://srv.local/Coll/Proj", "Repo", "https://srv.local/Coll/Proj/_git/Repo"); + + [Theory] + [InlineData("https://mika@srv.local/Coll/Proj/_git/Repo.git", "https://srv.local/Coll/Proj", "Repo")] + [InlineData("https://srv.local/tfs/Coll/Proj/_git/Mein%20Repo", "https://srv.local/tfs/Coll/Proj", "Mein Repo")] + public void ParseRemote_SelfTestCases(string remote, string apiBase, string repo) + { + var ctx = AzureDevOpsClient.ParseRemote(remote); + + Assert.NotNull(ctx); + Assert.Equal(apiBase, ctx!.ApiBase); + Assert.Equal(repo, ctx.Repo); + } + + [Fact] + public void ParseRemote_StripsCredentialsFromWebUrl() + { + var ctx = AzureDevOpsClient.ParseRemote("https://mika:pw@srv.local/Coll/Proj/_git/Repo.git"); + Assert.Equal("https://srv.local/Coll/Proj/_git/Repo", ctx!.WebUrl); + } + + [Theory] + [InlineData("https://example.com/mika/repo.git")] + [InlineData("C:/repos/coll/_git/repo")] + [InlineData("")] + public void ParseRemote_NonAdoRemote_IsNull(string remote) => Assert.Null(AzureDevOpsClient.ParseRemote(remote)); + + [Fact] + public void AuthHeader_MatchesScript() => Assert.Equal("Basic OmdlaGVpbQ==", AzureDevOpsClient.AuthHeaderValue("geheim")); + + [Fact] + public void GitAuthEnv_PassesPatAsExtraHeaderAndNeverPrompts() + { + var env = AzureDevOpsClient.GitAuthEnv("geheim"); + + Assert.Equal("0", env["GIT_TERMINAL_PROMPT"]); + Assert.Equal("1", env["GIT_CONFIG_COUNT"]); + Assert.Equal("http.extraHeader", env["GIT_CONFIG_KEY_0"]); + Assert.Equal("Authorization: Basic OmdlaGVpbQ==", env["GIT_CONFIG_VALUE_0"]); + Assert.False(AzureDevOpsClient.GitAuthEnv(null).ContainsKey("GIT_CONFIG_COUNT")); + } + + [Fact] + public void SupportedVersion_ReadsOnlyTheOutOfRangeError() + { + Assert.Equal("6.0", AzureDevOpsClient.SupportedVersion( + "The requested REST API version of 7.1 is out of range for this server. The latest version this server supports is 6.0.")); + Assert.Null(AzureDevOpsClient.SupportedVersion("TF401019: irgendein anderer Fehler")); + } + + [Fact] + public async Task EnsureDraftPullRequest_ReusesActivePr_WithoutPost() + { + var handler = new StubHandler(_ => (HttpStatusCode.OK, """{"value":[{"pullRequestId":42}],"count":1}""")); + + var (url, created) = await Client(handler).EnsureDraftPullRequestAsync(Repo, "feature/1-x", "main", "t", "d", default); + + Assert.False(created); + Assert.Equal("https://srv.local/Coll/Proj/_git/Repo/pullrequest/42", url); + Assert.Single(handler.Requests); + Assert.Contains("searchCriteria.sourceRefName=refs%2Fheads%2Ffeature%2F1-x", handler.Requests[0].RequestUri!.Query); + Assert.Contains("searchCriteria.status=active", handler.Requests[0].RequestUri!.Query); + Assert.Equal("Basic OmdlaGVpbQ==", handler.Requests[0].Headers.Authorization!.ToString()); + } + + [Fact] + public async Task EnsureDraftPullRequest_CreatesDraftWhenNoneActive() + { + var handler = new StubHandler(req => req.Method == HttpMethod.Get + ? (HttpStatusCode.OK, """{"value":[],"count":0}""") + : (HttpStatusCode.Created, """{"pullRequestId":7}""")); + + var (url, created) = await Client(handler).EnsureDraftPullRequestAsync(Repo, "feature/1-x", "dev", "Titel", "Beschreibung", default); + + Assert.True(created); + Assert.EndsWith("/pullrequest/7", url); + var body = JsonNode.Parse(handler.Bodies[1])!; + Assert.True(body["isDraft"]!.GetValue<bool>()); + Assert.Equal("refs/heads/feature/1-x", body["sourceRefName"]!.GetValue<string>()); + Assert.Equal("refs/heads/dev", body["targetRefName"]!.GetValue<string>()); + Assert.Equal("Titel", body["title"]!.GetValue<string>()); + Assert.Equal("Beschreibung", body["description"]!.GetValue<string>()); + Assert.Equal("https://srv.local/Coll/Proj/_apis/git/repositories/Repo/pullrequests", handler.Requests[1].RequestUri!.GetLeftPart(UriPartial.Path)); + } + + [Fact] + public async Task ApiVersion_FallsBackToTheVersionTheServerNames() + { + var handler = new StubHandler(req => req.RequestUri!.Query.Contains("api-version=6.0") + ? (HttpStatusCode.BadRequest, """{"message":"The requested REST API version of 6.0 is out of range for this server. The latest version this server supports is 5.1."}""") + : (HttpStatusCode.OK, """{"defaultBranch":"refs/heads/dev"}""")); + + var branch = await Client(handler).GetDefaultBranchAsync(Repo, default); + + Assert.Equal("dev", branch); + Assert.Equal(2, handler.Requests.Count); + Assert.Contains("api-version=5.1", handler.Requests[1].RequestUri!.Query); + } + + [Fact] + public async Task RestError_SurfacesStatusAndServerMessage() + { + var handler = new StubHandler(_ => (HttpStatusCode.Forbidden, """{"message":"TF401027: You need the Git 'PullRequestContribute' permission."}""")); + + var ex = await Assert.ThrowsAsync<AzureDevOpsException>(() => Client(handler).GetDefaultBranchAsync(Repo, default)); + + Assert.Contains("403", ex.Message); + Assert.Contains("TF401027", ex.Message); + } + + [Fact] + public async Task NoPat_FailsBeforeAnyRequest() + { + var handler = new StubHandler(_ => (HttpStatusCode.OK, "{}")); + + await Assert.ThrowsAsync<AzureDevOpsException>(() => Client(handler, pat: null).GetDefaultBranchAsync(Repo, default)); + Assert.Empty(handler.Requests); + } + + [Fact] + public async Task RejectedPat_203LoginPage_IsAPatError() + { + var handler = new StubHandler(_ => ((HttpStatusCode)203, "<html>login</html>")); + + var ex = await Assert.ThrowsAsync<AzureDevOpsException>(() => Client(handler).GetDefaultBranchAsync(Repo, default)); + Assert.Contains("PAT", ex.Message); + } +} + +internal sealed class StubHttpClientFactory(StubHandler handler) : IHttpClientFactory +{ + public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); +} From 7f7f23765629b679fcbed54c6bd92df4e7a31252 Mon Sep 17 00:00:00 2001 From: mika kuns <mika.kuns@fb-tuning.de> Date: Thu, 1 Oct 2026 11:59:11 +0200 Subject: [PATCH 05/12] feat(worker): company unit roots start from a fetched origin base, local base fast-forwarded best-effort --- src/ClaudeDo.Worker/Runner/WorktreeManager.cs | 89 +++++++++++- .../AzureDevOps/CompanyFetchBaseTests.cs | 134 ++++++++++++++++++ .../Infrastructure/GitRepoFixture.cs | 2 +- 3 files changed, 217 insertions(+), 8 deletions(-) create mode 100644 tests/ClaudeDo.Worker.Tests/AzureDevOps/CompanyFetchBaseTests.cs diff --git a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs index 6aa3fe61..3f446b22 100644 --- a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs +++ b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs @@ -2,6 +2,7 @@ using ClaudeDo.Data; using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.AzureDevOps; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Git; using ClaudeDo.Worker.Hub; @@ -24,16 +25,18 @@ public sealed class WorktreeManager private readonly WorkerConfig _cfg; private readonly ILogger<WorktreeManager> _logger; private readonly HubBroadcaster? _broadcaster; + private readonly AzureDevOpsClient? _azdo; public WorktreeManager( GitService git, IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerConfig cfg, - ILogger<WorktreeManager> logger, HubBroadcaster? broadcaster = null) + ILogger<WorktreeManager> logger, HubBroadcaster? broadcaster = null, AzureDevOpsClient? azdo = null) { _git = git; _dbFactory = dbFactory; _cfg = cfg; _logger = logger; _broadcaster = broadcaster; + _azdo = azdo; } private Task BroadcastCreating(string taskId) => @@ -76,8 +79,8 @@ public sealed class WorktreeManager if (await _git.EnsureHeadCommitAsync(workingDir, ct)) _logger.LogInformation("Repository at {Dir} had no commits; created an empty bootstrap commit", workingDir); - var baseCommit = await ResolveBaseCommitAsync(task, workingDir, ct); var isCompany = await CompanyRepo.IsCompanyAsync(_git, workingDir, _cfg.CompanyRemotePattern, ct); + var baseCommit = await ResolveBaseCommitAsync(task, workingDir, isCompany, ct); var name = NameFor(task, await ResolveTicketIdAsync(task, ct), isCompany); var branchType = BranchTypeFor(task.CommitType); var slug = CommitMessageBuilder.ToSlug(list.Name); @@ -261,9 +264,10 @@ public sealed class WorktreeManager /// <summary> /// A child of a parent with an Active worktree starts from the unit branch's current head, so it /// sees every sibling absorbed before it. Unit roots (and legacy children) base off - /// <c>list_config.base_branch</c>, else the current HEAD. + /// <c>list_config.base_branch</c>, else the current HEAD — in a company repo off a freshly fetched + /// <c>origin/<basis></c> (<see cref="FetchCompanyBaseAsync"/>). /// </summary> - private async Task<string> ResolveBaseCommitAsync(TaskEntity task, string workingDir, CancellationToken ct) + private async Task<string> ResolveBaseCommitAsync(TaskEntity task, string workingDir, bool isCompany, CancellationToken ct) { if (task.ParentTaskId is not null) { @@ -273,12 +277,83 @@ public sealed class WorktreeManager return await _git.RevParseAsync(workingDir, parentWt.BranchName, ct); } using var cfgCtx = _dbFactory.CreateDbContext(); - var baseBranch = await cfgCtx.ListConfigs.AsNoTracking() + var baseBranch = (await cfgCtx.ListConfigs.AsNoTracking() .Where(c => c.ListId == task.ListId) .Select(c => c.BaseBranch) - .FirstOrDefaultAsync(ct); - return string.IsNullOrWhiteSpace(baseBranch) + .FirstOrDefaultAsync(ct)).NullIfBlank(); + + if (isCompany && await FetchCompanyBaseAsync(workingDir, baseBranch, ct) is { } fetched) + return fetched; + + return baseBranch is null ? await _git.RevParseHeadAsync(workingDir, ct) : await _git.RevParseAsync(workingDir, baseBranch, ct); } + + /// <summary> + /// Fetches the basis (<c>base_branch</c>, else the ADO repo's default branch, else <c>origin/HEAD</c>) and + /// returns <c>origin/<basis></c>'s commit; the local basis is fast-forwarded best-effort. Null when no + /// basis is known or the fetch failed (offline, no credentials) — the caller then uses the local basis, + /// a fetch problem never fails the task. + /// </summary> + private async Task<string?> FetchCompanyBaseAsync(string workingDir, string? baseBranch, CancellationToken ct) + { + var remote = await _git.GetRemoteUrlAsync(workingDir, "origin", ct); + var pat = _azdo?.Pat; + var basis = baseBranch; + if (basis is null && _azdo is not null && pat is not null && AzureDevOpsClient.ParseRemote(remote) is { } repo) + { + try { basis = await _azdo.GetDefaultBranchAsync(repo, ct); } + catch (AzureDevOpsException ex) { _logger.LogWarning("Default branch from Azure DevOps unavailable: {reason}", ex.Message); } + } + basis ??= await _git.GetRemoteHeadBranchAsync(workingDir, ct); + if (basis is null) + { + _logger.LogWarning("Company repo {working_dir}: no base branch known, worktree starts from HEAD without fetch", workingDir); + return null; + } + + var (fetchExit, fetchErr) = await _git.FetchBranchAsync(workingDir, basis, AzureDevOpsClient.GitAuthEnv(pat), ct); + if (fetchExit != 0) + { + _logger.LogWarning("git fetch origin {branch} failed in {working_dir}, continuing with the local branch: {reason}", + basis, workingDir, fetchErr); + return baseBranch is null ? await TryRevParseAsync(workingDir, basis, ct) : null; + } + + await FastForwardLocalBaseAsync(workingDir, basis, ct); + return await _git.RevParseAsync(workingDir, $"refs/remotes/origin/{basis}", ct); + } + + /// <summary>Pulls the local basis up to <c>origin/<basis></c> without ever touching a dirty or diverged checkout.</summary> + private async Task FastForwardLocalBaseAsync(string workingDir, string basis, CancellationToken ct) + { + string? current; + try { current = await _git.GetCurrentBranchAsync(workingDir, ct); } + catch (InvalidOperationException) { current = null; } // detached HEAD + + (int ExitCode, string Stderr) result; + if (current == basis) + { + if (await _git.HasChangesAsync(workingDir, includeUntracked: false, ct)) + { + _logger.LogWarning("Local {branch} in {working_dir} has uncommitted changes, not fast-forwarded to origin", basis, workingDir); + return; + } + result = await _git.MergeFastForwardOnlyAsync(workingDir, $"refs/remotes/origin/{basis}", ct); + } + else + { + if (!(await _git.ListLocalBranchesAsync(workingDir, ct)).Contains(basis)) return; + result = await _git.FastForwardLocalBranchAsync(workingDir, basis, ct); + } + if (result.ExitCode != 0) + _logger.LogWarning("Local {branch} in {working_dir} not fast-forwarded to origin: {reason}", basis, workingDir, result.Stderr); + } + + private async Task<string?> TryRevParseAsync(string workingDir, string revision, CancellationToken ct) + { + try { return await _git.RevParseAsync(workingDir, revision, ct); } + catch (InvalidOperationException) { return null; } + } } diff --git a/tests/ClaudeDo.Worker.Tests/AzureDevOps/CompanyFetchBaseTests.cs b/tests/ClaudeDo.Worker.Tests/AzureDevOps/CompanyFetchBaseTests.cs new file mode 100644 index 00000000..50cfa8e8 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/AzureDevOps/CompanyFetchBaseTests.cs @@ -0,0 +1,134 @@ +using ClaudeDo.Data.Git; +using ClaudeDo.Data.Models; +using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.Git; +using ClaudeDo.Worker.Runner; +using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.AzureDevOps; + +/// <summary>Spec 2026-10-01 "Basis aktualisieren": a company unit root starts from a fetched origin/<basis>. Real git, local bare repo as origin.</summary> +public sealed class CompanyFetchBaseTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly GitRepoFixture _repo = new(); + private readonly string _tempDir = Path.Combine(Path.GetTempPath(), $"cd_fetch_{Guid.NewGuid():N}"); + private readonly string _bare; + private readonly string _listName = $"fb{Guid.NewGuid():N}"[..12]; + private readonly WorkerConfig _cfg; + + public CompanyFetchBaseTests() + { + Directory.CreateDirectory(_tempDir); + GitRepoFixture.RunGit(_repo.RepoDir, "branch", "-m", "main"); + _bare = Path.Combine(_tempDir, "cdbare_origin.git"); + GitRepoFixture.RunGit(_tempDir, "clone", "--bare", "-q", _repo.RepoDir, _bare); + GitRepoFixture.RunGit(_repo.RepoDir, "remote", "add", "origin", _bare); + _cfg = new WorkerConfig { SandboxRoot = _tempDir, LogRoot = _tempDir, WorktreeRootStrategy = "sibling", CompanyRemotePattern = "cdbare_" }; + + using var ctx = _db.CreateContext(); + ctx.Lists.Add(new ListEntity { Id = "l1", Name = _listName, WorkingDir = _repo.RepoDir, CreatedAt = DateTime.UtcNow }); + ctx.ListConfigs.Add(new ListConfigEntity { ListId = "l1", BaseBranch = "main" }); + ctx.Tasks.Add(new TaskEntity { Id = "t1", ListId = "l1", Title = "Root", Number = 1, CommitType = "feat", + Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow }); + ctx.SaveChanges(); + } + + public void Dispose() + { + try { Directory.Delete(Path.Combine(WorktreeRootResolver.ResolveSiblingRoot(_repo.RepoDir), ".claudedo-worktrees", _listName), true); } catch { } + _db.Dispose(); + _repo.Dispose(); + try { GitRepoFixture.ForceDeleteDirectory(_tempDir); } catch { } + } + + /// <summary>Pushes one commit to the bare origin from a scratch clone; returns its sha.</summary> + internal static string PushCommitToOrigin(string bare, string tempDir) + { + var clone = Path.Combine(tempDir, $"clone_{Guid.NewGuid():N}"); + GitRepoFixture.RunGit(tempDir, "clone", "-q", bare, clone); + GitRepoFixture.RunGit(clone, "config", "user.name", "test"); + GitRepoFixture.RunGit(clone, "config", "user.email", "test@example.com"); + File.WriteAllText(Path.Combine(clone, "upstream.txt"), "from colleague"); + GitRepoFixture.RunGit(clone, "add", "-A"); + GitRepoFixture.RunGit(clone, "commit", "-q", "-m", "upstream work"); + GitRepoFixture.RunGit(clone, "push", "-q", "origin", "HEAD:main"); + return GitRepoFixture.RunGit(clone, "rev-parse", "HEAD").Trim(); + } + + private Task<WorktreeContext> CreateRoot() + { + using var ctx = _db.CreateContext(); + var task = ctx.Tasks.Single(t => t.Id == "t1"); + var list = ctx.Lists.Single(); + return new WorktreeManager(new GitService(), _db.CreateFactory(), _cfg, NullLogger<WorktreeManager>.Instance) + .CreateAsync(task, list, default); + } + + private string Head(string rev = "HEAD") => GitRepoFixture.RunGit(_repo.RepoDir, "rev-parse", rev).Trim(); + + [Fact] + public async Task OriginAhead_RootStartsFromOrigin_AndCheckedOutCleanBaseIsFastForwarded() + { + var upstream = PushCommitToOrigin(_bare, _tempDir); + + var wt = await CreateRoot(); + + Assert.Equal(upstream, wt.BaseCommit); + Assert.Equal(upstream, Head()); + Assert.True(File.Exists(Path.Combine(wt.WorktreePath, "upstream.txt"))); + } + + [Fact] + public async Task DirtyCheckout_StaysUntouched_ButRootStillStartsFromOrigin() + { + var before = Head(); + var upstream = PushCommitToOrigin(_bare, _tempDir); + var readme = Path.Combine(_repo.RepoDir, "README.md"); + File.WriteAllText(readme, "local WIP"); + + var wt = await CreateRoot(); + + Assert.Equal(upstream, wt.BaseCommit); + Assert.Equal(before, Head()); + Assert.Equal("local WIP", File.ReadAllText(readme)); + } + + [Fact] + public async Task BaseNotCheckedOut_LocalBranchIsFastForwarded_CheckoutUnchanged() + { + GitRepoFixture.RunGit(_repo.RepoDir, "checkout", "-q", "-b", "other"); + var upstream = PushCommitToOrigin(_bare, _tempDir); + + var wt = await CreateRoot(); + + Assert.Equal(upstream, wt.BaseCommit); + Assert.Equal(upstream, Head("main")); + Assert.Equal("other", GitRepoFixture.RunGit(_repo.RepoDir, "branch", "--show-current").Trim()); + } + + [Fact] + public async Task FetchFailure_DoesNotFailTheTask_RootStartsFromLocalBase() + { + GitRepoFixture.RunGit(_repo.RepoDir, "remote", "set-url", "origin", Path.Combine(_tempDir, "cdbare_missing.git")); + var local = Head("main"); + + var wt = await CreateRoot(); + + Assert.Equal(local, wt.BaseCommit); + } + + [Fact] + public async Task PrivateRepo_IsNeverFetched() + { + _cfg.CompanyRemotePattern = "/_git/"; + var local = Head("main"); + PushCommitToOrigin(_bare, _tempDir); + + var wt = await CreateRoot(); + + Assert.Equal(local, wt.BaseCommit); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Infrastructure/GitRepoFixture.cs b/tests/ClaudeDo.Worker.Tests/Infrastructure/GitRepoFixture.cs index 4631ed3e..89e01b02 100644 --- a/tests/ClaudeDo.Worker.Tests/Infrastructure/GitRepoFixture.cs +++ b/tests/ClaudeDo.Worker.Tests/Infrastructure/GitRepoFixture.cs @@ -93,7 +93,7 @@ public sealed class GitRepoFixture : IDisposable RunGit(dir, "commit", "-m", "chore: seed"); } - private static void ForceDeleteDirectory(string path) + internal static void ForceDeleteDirectory(string path) { if (!Directory.Exists(path)) return; foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)) From bc959f25b3151a5926322988ea9259b1f6af6762 Mon Sep 17 00:00:00 2001 From: mika kuns <mika.kuns@fb-tuning.de> Date: Thu, 1 Oct 2026 12:05:33 +0200 Subject: [PATCH 06/12] feat(worker): approve in a company repo verifies in the worktree, pushes and opens a draft PR --- src/ClaudeDo.Data/Wire.cs | 4 +- .../AzureDevOps/PullRequestPublisher.cs | 164 +++++++++++++ .../External/ExternalMcpService.cs | 9 +- src/ClaudeDo.Worker/Hub/WorkerHub.cs | 4 +- .../Lifecycle/TaskMergeService.cs | 52 +++- src/ClaudeDo.Worker/Program.cs | 1 + .../AzureDevOps/CompanyApproveTests.cs | 229 ++++++++++++++++++ 7 files changed, 454 insertions(+), 9 deletions(-) create mode 100644 src/ClaudeDo.Worker/AzureDevOps/PullRequestPublisher.cs create mode 100644 tests/ClaudeDo.Worker.Tests/AzureDevOps/CompanyApproveTests.cs diff --git a/src/ClaudeDo.Data/Wire.cs b/src/ClaudeDo.Data/Wire.cs index fda7b5b5..714ffad4 100644 --- a/src/ClaudeDo.Data/Wire.cs +++ b/src/ClaudeDo.Data/Wire.cs @@ -81,7 +81,7 @@ public record PlanningMergeConflictStateDto(string PlanningTaskId, string Subtas public record PendingQuestionDto(string TaskId, string QuestionId, string Question); -public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage); +public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage, string? PullRequestUrl = null); public record BaseDirtyWarningDto(int ModifiedCount, int UntrackedCount); @@ -93,7 +93,7 @@ public record MergePreviewDto( public record MergeTargetsDto( string DefaultBranch, IReadOnlyList<string> LocalBranches, string DefaultCommitMessage, - bool TargetDirty = false); + bool TargetDirty = false, bool IsCompanyRepo = false); public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files); diff --git a/src/ClaudeDo.Worker/AzureDevOps/PullRequestPublisher.cs b/src/ClaudeDo.Worker/AzureDevOps/PullRequestPublisher.cs new file mode 100644 index 00000000..02277496 --- /dev/null +++ b/src/ClaudeDo.Worker/AzureDevOps/PullRequestPublisher.cs @@ -0,0 +1,164 @@ +using System.Text; +using ClaudeDo.Data; +using ClaudeDo.Data.Git; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.Git; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Lifecycle; +using ClaudeDo.Worker.Runner; +using ClaudeDo.Worker.Tickets; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Worker.AzureDevOps; + +/// <summary> +/// The company-repo end of a review: push the unit branch and find-or-create its draft PR on Azure DevOps. +/// Never touches task status or worktree state — the task stays WaitingForReview until the ADO merge. +/// </summary> +public sealed class PullRequestPublisher +{ + /// <summary>Web front of the Bandel ticket system; the API base URL (an IP) is not browsable.</summary> + public const string TicketWebBase = "https://ticket.fb-tuning.local"; + + private readonly GitService _git; + private readonly AzureDevOpsClient _azdo; + private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory; + private readonly HubBroadcaster _broadcaster; + private readonly WorkerConfig _cfg; + private readonly ILogger<PullRequestPublisher> _logger; + private readonly TicketClientFactory? _tickets; + + public PullRequestPublisher( + GitService git, AzureDevOpsClient azdo, IDbContextFactory<ClaudeDoDbContext> dbFactory, + HubBroadcaster broadcaster, WorkerConfig cfg, ILogger<PullRequestPublisher> logger, + TicketClientFactory? tickets = null) + { + _git = git; + _azdo = azdo; + _dbFactory = dbFactory; + _broadcaster = broadcaster; + _cfg = cfg; + _logger = logger; + _tickets = tickets; + } + + /// <summary>Live check of origin against <c>company_remote_pattern</c>.</summary> + public Task<bool> IsCompanyRepoAsync(string workingDir, CancellationToken ct) => + CompanyRepo.IsCompanyAsync(_git, workingDir, _cfg.CompanyRemotePattern, ct); + + /// <summary>Why a PR can't be published from <paramref name="workingDir"/>, or null — checked before the verify run so a missing PAT doesn't cost a build.</summary> + public async Task<string?> CheckReadyAsync(string workingDir, CancellationToken ct) + { + if (string.IsNullOrEmpty(_azdo.Pat)) + return "Kein Azure-DevOps-PAT hinterlegt (Einstellungen → Externe Dienste) — nichts gepusht."; + var remote = await _git.GetRemoteUrlAsync(workingDir, "origin", ct); + return AzureDevOpsClient.ParseRemote(remote) is null + ? $"origin ist kein https-Remote eines Azure DevOps Servers: {remote}" + : null; + } + + /// <summary>Push + draft PR; <paramref name="verifyCommand"/> is the command that passed (null = none ran).</summary> + public async Task<MergeResult> PublishAsync( + TaskEntity task, ListEntity list, WorktreeEntity wt, string? verifyCommand, CancellationToken ct) + { + var workingDir = list.WorkingDir!; + if (await CheckReadyAsync(workingDir, ct) is { } notReady) return Blocked(notReady); + var pat = _azdo.Pat!; + var repo = AzureDevOpsClient.ParseRemote(await _git.GetRemoteUrlAsync(workingDir, "origin", ct))!; + + var (pushExit, pushErr) = await _git.PushBranchAsync(wt.Path, wt.BranchName, AzureDevOpsClient.GitAuthEnv(pat), ct); + if (pushExit != 0) return Blocked($"push rejected: {pushErr}"); + + string url; + bool created; + try + { + var target = await ResolveTargetAsync(task.ListId, repo, ct); + if (target == wt.BranchName) + return Blocked($"gepusht, PR fehlgeschlagen: Quell- und Ziel-Branch sind beide '{target}'."); + var ticketId = TicketStatusMap.ParseBandelTicketId(task.TicketRef); + var title = BuildTitle(task, list, ticketId); + var description = BuildDescription(task, ticketId, await ResolveProjectIdAsync(ticketId, ct), verifyCommand); + (url, created) = await _azdo.EnsureDraftPullRequestAsync(repo, wt.BranchName, target, title, description, ct); + } + catch (AzureDevOpsException ex) + { + return Blocked($"gepusht, PR fehlgeschlagen: {ex.Message}"); + } + + using (var ctx = _dbFactory.CreateDbContext()) + await new WorktreeRepository(ctx).SetPullRequestUrlAsync(task.Id, url, ct); + await _broadcaster.WorktreeUpdated(task.Id); + _logger.LogInformation("Task {task_id}: branch {branch} pushed, draft PR {pr_url} ({pr_state})", + task.Id, wt.BranchName, url, created ? "created" : "reused"); + + return new MergeResult(created ? TaskMergeService.StatusPrCreated : TaskMergeService.StatusPrUpdated, + Array.Empty<string>(), null, url); + } + + /// <summary><c><type>(<list-slug>): <title>, Resolves #<ticket></c>.</summary> + public static string BuildTitle(TaskEntity task, ListEntity list, int? ticketId) + { + var type = string.IsNullOrWhiteSpace(task.CommitType) ? CommitTypeRegistry.DefaultType : task.CommitType.Trim(); + var slug = CommitMessageBuilder.ToSlug(list.Name); + var scope = slug.Length == 0 ? "" : $"({slug})"; + var title = $"{type}{scope}: {task.Title.Trim()}"; + return ticketId is null ? title : $"{title}, Resolves #{ticketId}"; + } + + /// <summary>Ticket link (plain <c>Resolves #id</c> without a project id), task description, and only what really ran.</summary> + public static string BuildDescription(TaskEntity task, int? ticketId, int? projectId, string? verifyCommand) + { + var sb = new StringBuilder(); + if (ticketId is { } id) + { + sb.Append(projectId is { } pid + ? $"Resolves [#{id}]({TicketWebBase}/board/project/{pid}/ticket/{id})" + : $"Resolves #{id}"); + sb.Append("\n\n"); + } + if (!string.IsNullOrWhiteSpace(task.Description)) + sb.Append(task.Description.Trim()).Append("\n\n"); + sb.Append(verifyCommand is null ? "Geprüft: nichts automatisch" : $"Geprüft: `{verifyCommand}` grün"); + return sb.ToString(); + } + + private async Task<string> ResolveTargetAsync(string listId, AzdoRepo repo, CancellationToken ct) + { + using var ctx = _dbFactory.CreateDbContext(); + var baseBranch = await ctx.ListConfigs.AsNoTracking() + .Where(c => c.ListId == listId) + .Select(c => c.BaseBranch) + .FirstOrDefaultAsync(ct); + return baseBranch.NullIfBlank() ?? await _azdo.GetDefaultBranchAsync(repo, ct); + } + + /// <summary> + /// The project an item lives in: a ticket's parent is its project, a task's parent is a ticket — so walk + /// up until <c>parentTicketID</c> is null (live-checked 2026-10-01). Null when unknown or unreachable. + /// </summary> + private async Task<int?> ResolveProjectIdAsync(int? ticketId, CancellationToken ct) + { + if (ticketId is null || _tickets?.Create() is not { } client) return null; + try + { + var id = ticketId.Value; + for (var hop = 0; hop < 3; hop++) + { + var detail = await client.GetTicketAsync(id, ct); + if (detail.ParentTicketId is not { } parent) return hop == 0 ? null : id; + id = parent; + } + } + catch (TicketApiException ex) + { + _logger.LogWarning("Project of ticket {ticket_id} unknown, PR links no ticket: {reason}", ticketId, ex.Message); + } + return null; + } + + private static MergeResult Blocked(string reason) => + new(TaskMergeService.StatusBlocked, Array.Empty<string>(), reason); +} diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index 0b036471..57e41932 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -636,7 +636,10 @@ public sealed class ExternalMcpService "means the merge stopped on conflicts, with the files listed. emptyChildren (parent approve only) lists " + "the Done children about to be unit-merged whose own review range contributed nothing (e.g. a child that " + "reported CLAUDEDO_BLOCKED and committed no code) — check it before trusting that every child actually " + - "delivered something." + McpToolDocs.LeanTaskRef + McpToolDocs.TaskNumberHint + McpToolDocs.ProgressHint)] + "delivered something. In a company repo (origin on the Azure DevOps Server) approve does not merge: it runs " + + "the verify command in the task's worktree (skipVerify is ignored), pushes the branch and opens or reuses a " + + "draft PR — mergeStatus 'pr_created'/'pr_updated', mergeMessage = the PR URL, the task stays WaitingForReview " + + "and needs no verify_merges." + McpToolDocs.LeanTaskRef + McpToolDocs.TaskNumberHint + McpToolDocs.ProgressHint)] public async Task<ReviewTaskResult> ReviewTask( string taskId, [Description("'approve', 'reject_rerun', 'reject_park' or 'cancel'.")] @@ -736,6 +739,10 @@ public sealed class ExternalMcpService mergeMessage = "merged; verify deferred — the task stays WaitingForReview until " + "verify_merges (with its id) passes"; } + else if (r.Status is TaskMergeService.StatusPrCreated or TaskMergeService.StatusPrUpdated) + { + mergeMessage = r.PullRequestUrl; + } else if (r.Status == TaskMergeService.StatusConflict) { if (leaveConflictsInTree) diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 54c319dd..69a52302 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -502,7 +502,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub => HubGuard(async () => { var t = await _mergeService.GetTargetsAsync(taskId, CancellationToken.None); - return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches, t.DefaultCommitMessage, t.TargetDirty); + return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches, t.DefaultCommitMessage, t.TargetDirty, t.IsCompanyRepo); }); public Task<MergePreviewDto> PreviewMerge(string taskId, string targetBranch) @@ -714,7 +714,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub var r = await _mergeService.ApproveAndMergeAsync(taskId, targetBranch ?? "", CancellationToken.None, autostash); if (r.Status == TaskMergeService.StatusBlocked) throw new HubException(r.ErrorMessage ?? "approve failed"); - return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage); + return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage, r.PullRequestUrl); }); public async Task RejectReviewToQueue(string taskId, string feedback) diff --git a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs index 00481abb..efa2662b 100644 --- a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs +++ b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs @@ -3,6 +3,7 @@ using ClaudeDo.Data; using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.AzureDevOps; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.State; @@ -15,13 +16,15 @@ namespace ClaudeDo.Worker.Lifecycle; public sealed record MergeResult( string Status, IReadOnlyList<string> ConflictFiles, - string? ErrorMessage); + string? ErrorMessage, + string? PullRequestUrl = null); public sealed record MergeTargets( string DefaultBranch, IReadOnlyList<string> LocalBranches, string DefaultCommitMessage, - bool TargetDirty = false); + bool TargetDirty = false, + bool IsCompanyRepo = false); // VerifyExitCode/VerifyDurationMs/VerifyOutputTail are only populated when the caller asked for // a verify run (runVerify=true) AND the list has a verify command configured AND the preview came @@ -76,6 +79,11 @@ public sealed class TaskMergeService // autostash: true) without matching on prose. public const string StatusDirtyTarget = "dirty_target"; + // Company repo: approve pushed the unit branch and opened (or reused) its draft PR; the task stays + // WaitingForReview, nothing landed locally. + public const string StatusPrCreated = "pr_created"; + public const string StatusPrUpdated = "pr_updated"; + public const string StatusReverted = "reverted"; public const string StatusConflictAborted = "conflict_aborted"; @@ -118,6 +126,7 @@ public sealed class TaskMergeService private readonly IVerifyCommandRunner _verify; private readonly ILogger<TaskMergeService> _logger; private readonly UnitBranchService? _unitBranch; + private readonly PullRequestPublisher? _pullRequests; public TaskMergeService( IDbContextFactory<ClaudeDoDbContext> dbFactory, @@ -126,7 +135,8 @@ public sealed class TaskMergeService ITaskStateService state, IVerifyCommandRunner verify, ILogger<TaskMergeService> logger, - UnitBranchService? unitBranch = null) + UnitBranchService? unitBranch = null, + PullRequestPublisher? pullRequests = null) { _dbFactory = dbFactory; _git = git; @@ -135,6 +145,7 @@ public sealed class TaskMergeService _verify = verify; _logger = logger; _unitBranch = unitBranch; + _pullRequests = pullRequests; } private async Task<(TaskEntity Task, ListEntity List, WorktreeEntity? Worktree, string? VerifyCommand)> LoadMergeContextAsync( @@ -881,7 +892,8 @@ public sealed class TaskMergeService var current = await ResolveTargetAsync(list, null, ct); var branches = await _git.ListLocalBranchesAsync(list.WorkingDir, ct); var dirty = await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct); - return new MergeTargets(current, branches, defaultMessage, dirty); + var company = _pullRequests is not null && await _pullRequests.IsCompanyRepoAsync(list.WorkingDir, ct); + return new MergeTargets(current, branches, defaultMessage, dirty, company); } /// <summary>Leeres Ziel → <c>list_config.base_branch</c>, sonst der ausgecheckte Branch des Main-Checkouts.</summary> @@ -1042,6 +1054,11 @@ public sealed class TaskMergeService } } + if (wt is { State: WorktreeState.Active } && _pullRequests is not null + && !string.IsNullOrWhiteSpace(list.WorkingDir) + && await _pullRequests.IsCompanyRepoAsync(list.WorkingDir, ct)) + return await ApproveCompanyAsync(task, list, wt, verifyCommand, ct, progress); + if (wt is not null && wt.State == WorktreeState.Kept) { // Kept means either RevertMergeAsync undid a previous merge, or the user manually @@ -1125,6 +1142,33 @@ public sealed class TaskMergeService return await MergeAsync(taskId, target, removeWorktree: true, commitMessage: "", leaveConflictsInTree, ct, progress, skipVerify, autostash); } + /// <summary> + /// Company repo: verify in the unit root's worktree (nothing lands on a local target, so there is no + /// merge to defer the verify to — <c>skipVerify</c> does not apply), then push + draft PR. Red verify + /// pushes nothing. Status, worktree state and ticket stay as they are. + /// </summary> + private async Task<MergeResult> ApproveCompanyAsync( + TaskEntity task, ListEntity list, WorktreeEntity wt, string? verifyCommand, CancellationToken ct, + IProgress<ProgressNotificationValue>? progress) + { + if (await _pullRequests!.CheckReadyAsync(list.WorkingDir!, ct) is { } notReady) + return Blocked(notReady); + + var ranVerify = !string.IsNullOrWhiteSpace(verifyCommand); + if (ranVerify) + { + await _broadcaster.OperationProgress(task.Id, PhaseVerifying, 0, 0); + var failed = await RunVerifyGateAsync( + verifyCommand, wt.Path, ct, progress, + elapsed => _ = _broadcaster.OperationProgress(task.Id, PhaseVerifying, (int)elapsed.TotalSeconds, 0)); + if (failed is not null) return failed; + } + + return await ProgressReporter.RunAsync( + _pullRequests.PublishAsync(task, list, wt, ranVerify ? verifyCommand : null, ct), + ProgressReportInterval, progress, "push + draft PR running"); + } + /// <summary> /// Redoes a merge that <see cref="RevertMergeAsync"/> undid (or that predates it, if the branch /// was already merged before its worktree reached <see cref="WorktreeState.Kept"/>), by diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index ef037935..dfc9bd35 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -257,6 +257,7 @@ builder.Services.AddSingleton<TicketStatusSync>(); builder.Services.AddSingleton<TicketImportService>(); builder.Services.AddHttpClient("azdo", c => c.Timeout = TimeSpan.FromSeconds(30)); builder.Services.AddSingleton<AzureDevOpsClient>(); +builder.Services.AddSingleton<PullRequestPublisher>(); if (cfg.OnlineInbox.Enabled) { diff --git a/tests/ClaudeDo.Worker.Tests/AzureDevOps/CompanyApproveTests.cs b/tests/ClaudeDo.Worker.Tests/AzureDevOps/CompanyApproveTests.cs new file mode 100644 index 00000000..50fb250d --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/AzureDevOps/CompanyApproveTests.cs @@ -0,0 +1,229 @@ +using System.Net; +using System.Text.Json.Nodes; +using ClaudeDo.Data.Git; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.AzureDevOps; +using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.Git; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Lifecycle; +using ClaudeDo.Worker.Runner; +using ClaudeDo.Worker.Tests.Infrastructure; +using ClaudeDo.Worker.Tests.Services; +using ClaudeDo.Worker.Tests.Tickets; +using ClaudeDo.Worker.Tickets; +using Microsoft.Extensions.Logging.Abstractions; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.AzureDevOps; + +/// <summary> +/// Spec 2026-10-01 section 3: approve in a company repo = verify in the worktree, push, draft PR. origin is an +/// ADO-shaped URL so it parses, and <c>pushInsteadOf</c> sends the push into a local bare repo. HTTP stubbed. +/// </summary> +public sealed class CompanyApproveTests : IDisposable +{ + private const string Remote = "http://ado.test/coll/proj/_git/Repo"; + + private readonly DbFixture _db = new(); + private readonly GitRepoFixture _repo = new(); + private readonly string _tempDir = Path.Combine(Path.GetTempPath(), $"cd_capr_{Guid.NewGuid():N}"); + private readonly string _bare; + private readonly string _listName = $"ca{Guid.NewGuid():N}"[..12]; + private readonly WorkerConfig _cfg; + private readonly GitService _git = new(); + private readonly FakeVerifyCommandRunner _verify = new(); + private readonly HubBroadcaster _broadcaster = new(new CapturingHubContext()); + private WorktreeContext _wt = null!; + + public CompanyApproveTests() + { + Directory.CreateDirectory(_tempDir); + GitRepoFixture.RunGit(_repo.RepoDir, "branch", "-m", "main"); + _bare = Path.Combine(_tempDir, "origin.git").Replace('\\', '/'); + GitRepoFixture.RunGit(_tempDir, "clone", "--bare", "-q", _repo.RepoDir, _bare); + _cfg = new WorkerConfig { SandboxRoot = _tempDir, LogRoot = _tempDir, WorktreeRootStrategy = "sibling" }; + + using var ctx = _db.CreateContext(); + ctx.Lists.Add(new ListEntity { Id = "l1", Name = _listName, WorkingDir = _repo.RepoDir, CreatedAt = DateTime.UtcNow }); + ctx.ListConfigs.Add(new ListConfigEntity { ListId = "l1", VerifyCommand = "dotnet test" }); + ctx.Tasks.Add(new TaskEntity { Id = "t1", ListId = "l1", Title = "Scan erfassen", Number = 1, CommitType = "feat", + Description = "Scanner-Parität", TicketRef = "bandel:412", Status = TaskStatus.WaitingForReview, CreatedAt = DateTime.UtcNow }); + ctx.SaveChanges(); + } + + public void Dispose() + { + try { GitRepoFixture.RunGit(_repo.RepoDir, "worktree", "remove", "--force", _wt.WorktreePath); } catch { } + try { Directory.Delete(Path.Combine(WorktreeRootResolver.ResolveSiblingRoot(_repo.RepoDir), ".claudedo-worktrees", _listName), true); } catch { } + _db.Dispose(); + _repo.Dispose(); + try { GitRepoFixture.ForceDeleteDirectory(_tempDir); } catch { } + } + + /// <summary>Worktree with one commit, created while the repo is still private (no fetch), then origin turns ADO.</summary> + private async Task SetUpWorktree() + { + TaskEntity task; ListEntity list; + using (var ctx = _db.CreateContext()) { task = ctx.Tasks.Single(); list = ctx.Lists.Single(); } + var manager = new WorktreeManager(_git, _db.CreateFactory(), _cfg, NullLogger<WorktreeManager>.Instance); + _wt = await manager.CreateAsync(task, list, default); + File.WriteAllText(Path.Combine(_wt.WorktreePath, "scan.txt"), "scan"); + GitRepoFixture.RunGit(_wt.WorktreePath, "add", "-A"); + GitRepoFixture.RunGit(_wt.WorktreePath, "commit", "-q", "-m", "scan"); + + GitRepoFixture.RunGit(_repo.RepoDir, "remote", "add", "origin", Remote); + GitRepoFixture.RunGit(_repo.RepoDir, "config", $"url.{_bare}.pushInsteadOf", Remote); + } + + private TaskMergeService Merge(StubHandler handler, string? pat = "geheim", bool tickets = false) + { + var azdo = new AzureDevOpsClient(new StubHttpClientFactory(handler), AzureDevOpsClientTests.PatStore(_tempDir, pat)); + TicketClientFactory? ticketClients = null; + if (tickets) + { + var ticketPat = new TicketPatStore(new DpapiTokenStore(Path.Combine(_tempDir, "ticket.pat"))); + ticketPat.Store.Save("tsp_x"); + var config = new TicketSystemConfig(new WorkerConfig { TicketApiBaseUrl = "http://tickets.test" }, ticketPat); + ticketClients = new TicketClientFactory(config, new StubHttpClientFactory(handler), NullLogger<BandelTicketClient>.Instance); + } + var publisher = new PullRequestPublisher(_git, azdo, _db.CreateFactory(), _broadcaster, _cfg, + NullLogger<PullRequestPublisher>.Instance, ticketClients); + return new TaskMergeService(_db.CreateFactory(), _git, _broadcaster, TaskStateServiceBuilder.Build(_db.CreateFactory()).State, + _verify, NullLogger<TaskMergeService>.Instance, pullRequests: publisher); + } + + private static StubHandler Ado(string existingPrs = "[]", HttpStatusCode postStatus = HttpStatusCode.Created) => new(req => + req.RequestUri!.Host == "tickets.test" + ? (HttpStatusCode.OK, req.RequestUri.AbsolutePath switch + { + // Task 412 → ticket 400 → project 393 (live shape: a project has no parent). + "/api/ticket/412" => Envelope("""{"id":412,"parentTicketID":400}"""), + "/api/ticket/400" => Envelope("""{"id":400,"parentTicketID":393}"""), + _ => Envelope("""{"id":393,"parentTicketID":null}"""), + }) + : req.RequestUri!.AbsolutePath.EndsWith("/pullrequests") + ? req.Method == HttpMethod.Get + ? (HttpStatusCode.OK, $$"""{"value":{{existingPrs}}}""") + : (postStatus, postStatus == HttpStatusCode.Created ? """{"pullRequestId":5}""" : """{"message":"TF0: server error"}""") + : (HttpStatusCode.OK, """{"defaultBranch":"refs/heads/main"}""")); + + private static string Envelope(string data) => $$"""{"success":true,"data":{{data}},"message":"ok"}"""; + + private bool BareHasBranch() => + GitRepoFixture.RunGit(_bare, "branch", "--list", _wt.BranchName).Trim().Length > 0; + + private async Task<(TaskEntity Task, WorktreeEntity Wt)> Rows() + { + using var ctx = _db.CreateContext(); + return ((await new TaskRepository(ctx).GetByIdAsync("t1"))!, (await new WorktreeRepository(ctx).GetByTaskIdAsync("t1"))!); + } + + [Fact] + public async Task Approve_VerifiesInWorktree_PushesAndCreatesDraftPr_TaskStaysInReview() + { + if (!GitRepoFixture.IsGitAvailable()) return; + await SetUpWorktree(); + var ado = Ado(); + + var r = await Merge(ado).ApproveAndMergeAsync("t1", "", default); + + Assert.Equal(TaskMergeService.StatusPrCreated, r.Status); + Assert.Equal($"{Remote}/pullrequest/5", r.PullRequestUrl); + Assert.Equal(_wt.WorktreePath, _verify.CapturedWorkingDir); + Assert.True(BareHasBranch()); + var (task, wt) = await Rows(); + Assert.Equal(TaskStatus.WaitingForReview, task.Status); + Assert.Equal(WorktreeState.Active, wt.State); + Assert.Equal(r.PullRequestUrl, wt.PullRequestUrl); + + var body = JsonNode.Parse(ado.Bodies.Last())!; + Assert.Equal($"feat({_listName}): Scan erfassen, Resolves #412", body["title"]!.GetValue<string>()); + Assert.Equal("Resolves #412\n\nScanner-Parität\n\nGeprüft: `dotnet test` grün", body["description"]!.GetValue<string>()); + Assert.Equal("refs/heads/main", body["targetRefName"]!.GetValue<string>()); + Assert.True(body["isDraft"]!.GetValue<bool>()); + } + + [Fact] + public async Task Approve_LinksTicketUnderItsProject_WalkingUpFromATask() + { + if (!GitRepoFixture.IsGitAvailable()) return; + await SetUpWorktree(); + var ado = Ado(); + + await Merge(ado, tickets: true).ApproveAndMergeAsync("t1", "", default); + + var description = JsonNode.Parse(ado.Bodies.Last())!["description"]!.GetValue<string>(); + Assert.StartsWith("Resolves [#412](https://ticket.fb-tuning.local/board/project/393/ticket/412)", description); + } + + [Fact] + public async Task Approve_ReusesActivePr_AsUpdate() + { + if (!GitRepoFixture.IsGitAvailable()) return; + await SetUpWorktree(); + var ado = Ado(existingPrs: """[{"pullRequestId":9}]"""); + + var r = await Merge(ado).ApproveAndMergeAsync("t1", "", default); + + Assert.Equal(TaskMergeService.StatusPrUpdated, r.Status); + Assert.EndsWith("/pullrequest/9", r.PullRequestUrl); + Assert.DoesNotContain(ado.Requests, q => q.Method == HttpMethod.Post); + } + + [Fact] + public async Task Approve_RedVerify_PushesNothing() + { + if (!GitRepoFixture.IsGitAvailable()) return; + await SetUpWorktree(); + _verify.Result = new VerifyCommandResult(1, false, "boom"); + var ado = Ado(); + + var r = await Merge(ado).ApproveAndMergeAsync("t1", "", default); + + Assert.Equal(TaskMergeService.StatusVerifyFailed, r.Status); + Assert.False(BareHasBranch()); + Assert.Empty(ado.Requests); + } + + [Fact] + public async Task Approve_PrFailsAfterPush_SaysSoHonestly() + { + if (!GitRepoFixture.IsGitAvailable()) return; + await SetUpWorktree(); + + var r = await Merge(Ado(postStatus: HttpStatusCode.InternalServerError)).ApproveAndMergeAsync("t1", "", default); + + Assert.Equal(TaskMergeService.StatusBlocked, r.Status); + Assert.StartsWith("gepusht, PR fehlgeschlagen", r.ErrorMessage); + Assert.True(BareHasBranch()); + Assert.Null((await Rows()).Wt.PullRequestUrl); + } + + [Fact] + public async Task Approve_SkipVerifyIsIgnored() + { + if (!GitRepoFixture.IsGitAvailable()) return; + await SetUpWorktree(); + + var r = await Merge(Ado()).ApproveAndMergeAsync("t1", "", leaveConflictsInTree: false, default, skipVerify: true); + + Assert.Equal(TaskMergeService.StatusPrCreated, r.Status); + Assert.Equal(1, _verify.CallCount); + } + + [Fact] + public async Task Approve_WithoutPat_BlocksBeforeVerifyAndPush() + { + if (!GitRepoFixture.IsGitAvailable()) return; + await SetUpWorktree(); + + var r = await Merge(Ado(), pat: null).ApproveAndMergeAsync("t1", "", default); + + Assert.Equal(TaskMergeService.StatusBlocked, r.Status); + Assert.Contains("PAT", r.ErrorMessage); + Assert.Equal(0, _verify.CallCount); + Assert.False(BareHasBranch()); + } +} From fed0b6ac51d44b521fce0dee2bc88cec44b40dcf Mon Sep 17 00:00:00 2001 From: mika kuns <mika.kuns@fb-tuning.de> Date: Thu, 1 Oct 2026 12:06:56 +0200 Subject: [PATCH 07/12] feat(hub): Azure DevOps PAT settings and connection test --- src/ClaudeDo.Data/Wire.cs | 3 ++ .../Services/Interfaces/IWorkerClient.cs | 4 ++ src/ClaudeDo.Ui/Services/WorkerClient.cs | 12 +++++ src/ClaudeDo.Worker/Hub/WorkerHub.cs | 48 ++++++++++++++++++- tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs | 4 ++ 5 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/ClaudeDo.Data/Wire.cs b/src/ClaudeDo.Data/Wire.cs index 714ffad4..17fb7f1b 100644 --- a/src/ClaudeDo.Data/Wire.cs +++ b/src/ClaudeDo.Data/Wire.cs @@ -225,5 +225,8 @@ public record HandlerOpenTaskDto(string Id, string Title); public record TicketSettingsDto(string? ApiBaseUrl, bool TokenSet, string? FallbackListId = null); public record TicketConnectionDto(bool Ok, string? UserName, IReadOnlyList<string> Scopes, string? Error); +public record AzureDevOpsSettingsDto(bool TokenSet); +// Repo = the company repo the test ran against (first list whose origin matches), DefaultBranch = its ADO default. +public record AzureDevOpsConnectionDto(bool Ok, string? Repo, string? DefaultBranch, string? Error); public record TicketProjectDto(int Id, string Title, string DepartmentName); public record TicketImportResultDto(int Examined, int Created); diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs index a7f046ff..f830e511 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -252,6 +252,10 @@ public interface IWorkerClient : INotifyPropertyChanged Task SetTicketTokenAsync(string token); Task ClearTicketTokenAsync(); Task<TicketConnectionDto?> TestTicketConnectionAsync(); + Task<AzureDevOpsSettingsDto?> GetAzureDevOpsSettingsAsync(); + Task SetAzureDevOpsTokenAsync(string token); + Task ClearAzureDevOpsTokenAsync(); + Task<AzureDevOpsConnectionDto?> TestAzureDevOpsConnectionAsync(); Task<List<TicketProjectDto>> GetTicketProjectsAsync(); /// <summary>Imports open tickets assigned to the token owner into the given list. Throws /// HubException (with a user-readable message) on failure so the caller can surface it via diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 819f6b85..698a0aa8 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -776,6 +776,18 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public Task<TicketConnectionDto?> TestTicketConnectionAsync() => TryInvokeAsync<TicketConnectionDto>("TestTicketConnection"); + public Task<AzureDevOpsSettingsDto?> GetAzureDevOpsSettingsAsync() + => TryInvokeAsync<AzureDevOpsSettingsDto>("GetAzureDevOpsSettings"); + + public Task SetAzureDevOpsTokenAsync(string token) + => InvokeTimedAsync("SetAzureDevOpsToken", () => _hub.InvokeAsync("SetAzureDevOpsToken", token)); + + public Task ClearAzureDevOpsTokenAsync() + => InvokeTimedAsync("ClearAzureDevOpsToken", () => _hub.InvokeAsync("ClearAzureDevOpsToken")); + + public Task<AzureDevOpsConnectionDto?> TestAzureDevOpsConnectionAsync() + => TryInvokeAsync<AzureDevOpsConnectionDto>("TestAzureDevOpsConnection"); + public async Task<List<TicketProjectDto>> GetTicketProjectsAsync() => await TryInvokeAsync<List<TicketProjectDto>>("GetTicketProjects") ?? []; diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 69a52302..dbea9f36 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -18,6 +18,7 @@ using ClaudeDo.Worker.Report; using ClaudeDo.Worker.Report.Interfaces; using ClaudeDo.Worker.Skills; using ClaudeDo.Worker.State; +using ClaudeDo.Worker.AzureDevOps; using ClaudeDo.Worker.Tickets; using ClaudeDo.Worker.Usage; using ClaudeDo.Worker.Usage.Interfaces; @@ -75,6 +76,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub private readonly HandlerRestartCoordinator? _handlerRestart; private readonly HandlerRoundState? _roundState; private readonly ConnectedClientTracker? _clientTracker; + private readonly AzureDevOpsPatStore? _azdoPat; + private readonly AzureDevOpsClient? _azdo; public WorkerHub( QueueService queue, @@ -116,7 +119,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub TicketImportService ticketImport = null!, HandlerRestartCoordinator? handlerRestart = null, HandlerRoundState? roundState = null, - ConnectedClientTracker? clientTracker = null) + ConnectedClientTracker? clientTracker = null, + AzureDevOpsPatStore? azdoPat = null, + AzureDevOpsClient? azdo = null) { _queue = queue; _waker = waker; @@ -158,6 +163,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub _handlerRestart = handlerRestart; _roundState = roundState; _clientTracker = clientTracker; + _azdoPat = azdoPat; + _azdo = azdo; } // Tracks connected UI clients so an out-of-band caller (external MCP's start_list_handler) @@ -1421,6 +1428,45 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub } } + public AzureDevOpsSettingsDto GetAzureDevOpsSettings() => new(_azdoPat?.Store.Exists() == true); + + public void SetAzureDevOpsToken(string token) + { + if (_azdoPat is null) return; + if (string.IsNullOrWhiteSpace(token)) _azdoPat.Store.Clear(); + else _azdoPat.Store.Save(token.Trim()); + } + + public void ClearAzureDevOpsToken() => _azdoPat?.Store.Clear(); + + /// <summary>Reads the first company repo's ADO repository (default branch). Errors are answers, not throws — same reason as TestTicketConnection.</summary> + public async Task<AzureDevOpsConnectionDto> TestAzureDevOpsConnection() + { + if (_azdo is null) return new AzureDevOpsConnectionDto(false, null, null, "Azure DevOps ist im Worker nicht registriert."); + try + { + var git = _git ?? new Data.Git.GitService(); + List<string> dirs; + await using (var ctx = await _dbFactory.CreateDbContextAsync(Context.ConnectionAborted)) + dirs = await ctx.Lists.AsNoTracking().Where(l => l.WorkingDir != null).Select(l => l.WorkingDir!).ToListAsync(Context.ConnectionAborted); + + foreach (var dir in dirs) + { + if (!Directory.Exists(dir)) continue; + var remote = await git.GetRemoteUrlAsync(dir, "origin", Context.ConnectionAborted); + if (!Git.CompanyRepo.Matches(remote, _cfg.CompanyRemotePattern) || AzureDevOpsClient.ParseRemote(remote) is not { } repo) + continue; + var branch = await _azdo.GetDefaultBranchAsync(repo, Context.ConnectionAborted); + return new AzureDevOpsConnectionDto(true, repo.Repo, branch, null); + } + return new AzureDevOpsConnectionDto(false, null, null, "Keine Liste zeigt auf ein Firmen-Repo (origin mit " + _cfg.CompanyRemotePattern + ")."); + } + catch (Exception ex) + { + return new AzureDevOpsConnectionDto(false, null, null, ex.Message); + } + } + public async Task<List<TicketProjectDto>> GetTicketProjects() { var client = _ticketClients.Create(); diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs index a8826097..f4bb5306 100644 --- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs +++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs @@ -227,6 +227,10 @@ public abstract class StubWorkerClient : IWorkerClient public virtual Task SetTicketTokenAsync(string token) => Task.CompletedTask; public virtual Task ClearTicketTokenAsync() => Task.CompletedTask; public virtual Task<TicketConnectionDto?> TestTicketConnectionAsync() => Task.FromResult<TicketConnectionDto?>(null); + public virtual Task<AzureDevOpsSettingsDto?> GetAzureDevOpsSettingsAsync() => Task.FromResult<AzureDevOpsSettingsDto?>(null); + public virtual Task SetAzureDevOpsTokenAsync(string token) => Task.CompletedTask; + public virtual Task ClearAzureDevOpsTokenAsync() => Task.CompletedTask; + public virtual Task<AzureDevOpsConnectionDto?> TestAzureDevOpsConnectionAsync() => Task.FromResult<AzureDevOpsConnectionDto?>(null); public virtual Task<List<TicketProjectDto>> GetTicketProjectsAsync() => Task.FromResult(new List<TicketProjectDto>()); public virtual Task<TicketImportResultDto> ImportTicketsAsync(string listId) => Task.FromResult(new TicketImportResultDto(0, 0)); From 4976ded7ae6fc0b4cc58786127c02802e42ce52d Mon Sep 17 00:00:00 2001 From: mika kuns <mika.kuns@fb-tuning.de> Date: Thu, 1 Oct 2026 12:13:57 +0200 Subject: [PATCH 08/12] feat(ui): external services tab with Azure DevOps PAT, draft PR button and link in company repos --- src/ClaudeDo.Localization/locales/de.json | 15 ++++-- src/ClaudeDo.Localization/locales/en.json | 15 ++++-- .../Islands/DetailsIslandViewModel.cs | 22 +++++++-- .../Islands/MergeSectionViewModel.cs | 25 ++++++++++ .../Settings/TicketSettingsTabViewModel.cs | 44 ++++++++++++++++++ .../Views/Islands/Detail/WorkConsole.axaml | 24 ++++++++-- .../Views/Modals/SettingsModalView.axaml | 33 ++++++++++++- .../MergeSectionCompanyRepoTests.cs | 46 +++++++++++++++++++ .../TicketSettingsTabViewModelTests.cs | 38 +++++++++++++++ 9 files changed, 247 insertions(+), 15 deletions(-) create mode 100644 tests/ClaudeDo.Ui.Tests/ViewModels/MergeSectionCompanyRepoTests.cs diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index c7d20b2a..c454dc78 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -16,7 +16,7 @@ "categoryFiles": "Dateien & Prompts", "categorySkills": "Session Skills", "categoryReports": "Berichte", - "categoryTickets": "Ticketsystem" + "categoryTickets": "Externe Dienste" }, "repoHint": { "text": "Wirkt erst mit verknüpftem Repo.", @@ -148,6 +148,10 @@ "saveButton": "Konfiguration speichern" }, "tickets": { + "ticketSection": "TICKETSYSTEM", + "azdoSection": "AZURE DEVOPS", + "azdoToken": "Personal Access Token (Scope: Code Read & Write)", + "azdoHint": "Gilt in Firmen-Repos (origin auf dem Azure DevOps Server): Fetch vor dem Task-Start, Push und Draft-PR beim Genehmigen.", "baseUrl": "API-Base-URL", "baseUrlHint": "Leer lassen, um die Ticket-Anbindung komplett zu deaktivieren.", "token": "Personal Access Token", @@ -335,6 +339,10 @@ "openDiffHint": "Diff öffnen, um Mergen freizuschalten", "targetDirty": "Ziel-Arbeitsverzeichnis hat uncommittete Änderungen — beim Mergen werden sie gestasht und danach wiederhergestellt.", "approveMerge": "Genehmigen & Mergen", + "createDraftPr": "Draft-PR erstellen", + "updatePr": "PR aktualisieren", + "openPr": "Pull Request öffnen", + "companyTargetHint": "Firmen-Repo — Genehmigen pusht den Branch und erstellt einen Draft-PR auf Azure DevOps.", "park": "Parken", "parkTip": "Zurückstellen — zurück auf Leerlauf, Worktree bleibt erhalten", "cancel": "Abbrechen", @@ -885,7 +893,7 @@ "merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Wird zusammengeführt…", "progressVerifying": "Verify-Kommando der Liste läuft… ({0})", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "dirtyTarget": "Merge abgelehnt — das Ziel-Arbeitsverzeichnis hat uncommittete Änderungen.", "targetDirty": "Das Ziel-Arbeitsverzeichnis hat bereits uncommittete Änderungen — stashen und mergen?", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" }, "settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" }, "onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signedInNoRole": "Angemeldet, aber diesem Konto fehlt die Rolle 'user' in Zitadel — die Online-Synchronisierung wird abgelehnt, bis die Rolle im ClaudeDo-Projekt zugewiesen wird.", "signInFailed": "Anmeldung fehlgeschlagen: {0}", "signedOut": "Abgemeldet.", "signOutFailed": "Abmeldung fehlgeschlagen: {0}" }, - "tickets": { "connectionOk": "Verbunden als {0}.", "connectionMissingScopes": "Verbunden als {0}, aber es fehlen Scopes: {1}", "tokenCleared": "Token entfernt.", "workerOffline": "Worker nicht erreichbar." }, + "tickets": { "azdoConnectionOk": "Verbunden — Repo {0}, Default-Branch {1}.", "connectionOk": "Verbunden als {0}.", "connectionMissingScopes": "Verbunden als {0}, aber es fehlen Scopes: {1}", "tokenCleared": "Token entfernt.", "workerOffline": "Worker nicht erreichbar." }, "weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "error": "Fehler: {0}" }, "usageMonitor": { "loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}", @@ -912,7 +920,8 @@ "stalledHint": "Läuft weiter — Worker antwortet noch", "merge": { "merging": "Wird zusammengeführt…", - "verifying": "Verify-Kommando der Liste läuft… ({0})" + "verifying": "Verify-Kommando der Liste läuft… ({0})", + "publishing": "Push und Draft-PR laufen…" }, "diff": { "parsing": "Diff wird geparst…" diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 8753c160..53a60d5e 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -16,7 +16,7 @@ "categoryFiles": "Files & Prompts", "categorySkills": "Session Skills", "categoryReports": "Reports", - "categoryTickets": "Ticket system" + "categoryTickets": "External services" }, "repoHint": { "text": "Only takes effect once a repo is linked.", @@ -148,6 +148,10 @@ "saveButton": "Save config" }, "tickets": { + "ticketSection": "TICKET SYSTEM", + "azdoSection": "AZURE DEVOPS", + "azdoToken": "Personal access token (scope: Code read & write)", + "azdoHint": "Used in company repos (origin on the Azure DevOps Server): fetch before a task starts, push and draft PR on approve.", "baseUrl": "API base URL", "baseUrlHint": "Leave empty to disable the ticket integration entirely.", "token": "Personal access token", @@ -335,6 +339,10 @@ "openDiffHint": "Open the diff to enable merge", "targetDirty": "Target working directory has uncommitted changes — merging will stash and restore them.", "approveMerge": "Approve & Merge", + "createDraftPr": "Create draft PR", + "updatePr": "Update PR", + "openPr": "Open pull request", + "companyTargetHint": "Company repo — approve pushes the branch and opens a draft PR on Azure DevOps.", "park": "Park", "parkTip": "Set aside — back to Idle, keeps the worktree", "cancel": "Cancel", @@ -885,7 +893,7 @@ "merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Merging…", "progressVerifying": "Running the list's verify command… ({0})", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "dirtyTarget": "Merge refused — the target working directory has uncommitted changes.", "targetDirty": "The target working directory already has uncommitted changes — stash them and merge?", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" }, "settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" }, "onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signedInNoRole": "Signed in, but this account is missing the 'user' role in Zitadel — online sync will be rejected until the role is granted in the ClaudeDo project.", "signInFailed": "Sign-in failed: {0}", "signedOut": "Signed out.", "signOutFailed": "Sign-out failed: {0}" }, - "tickets": { "connectionOk": "Connected as {0}.", "connectionMissingScopes": "Connected as {0}, but missing scopes: {1}", "tokenCleared": "Token removed.", "workerOffline": "Worker not reachable." }, + "tickets": { "azdoConnectionOk": "Connected — repo {0}, default branch {1}.", "connectionOk": "Connected as {0}.", "connectionMissingScopes": "Connected as {0}, but missing scopes: {1}", "tokenCleared": "Token removed.", "workerOffline": "Worker not reachable." }, "weeklyReport": { "invalidRange": "Invalid date range.", "error": "Error: {0}" }, "usageMonitor": { "loadFailed": "Couldn't load usage data: {0}", @@ -912,7 +920,8 @@ "stalledHint": "Still running — worker hasn't responded in a while", "merge": { "merging": "Merging…", - "verifying": "Running the list's verify command… ({0})" + "verifying": "Running the list's verify command… ({0})", + "publishing": "Pushing and opening the draft PR…" }, "diff": { "parsing": "Parsing diff…" diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index 23154686..9eadff47 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -676,6 +676,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable Merge.SyncTaskContext(row.Id, row.Title, row.IsPlanningParent); Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit, WorktreeStateLabel, _listWorkingDir); + Merge.PullRequestUrl = entity.Worktree?.PullRequestUrl; await AgentSettings.LoadForTaskAsync(entity, ct); ct.ThrowIfCancellationRequested(); @@ -728,6 +729,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable foreach (var b in targets.LocalBranches) Merge.MergeTargetBranches.Add(b); Merge.SelectedMergeTarget = targets.DefaultBranch; Merge.TargetDirty = targets.TargetDirty; + Merge.IsCompanyRepo = targets.IsCompanyRepo; } } await Merge.RefreshMergePreviewAsync(); @@ -779,6 +781,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable Merge.MergeTargetBranches.Add(b); Merge.SelectedMergeTarget = targets.DefaultBranch; Merge.TargetDirty = targets.TargetDirty; + Merge.IsCompanyRepo = targets.IsCompanyRepo; } } } @@ -826,6 +829,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable Merge.MergeTargetBranches.Add(b); Merge.SelectedMergeTarget = targets.DefaultBranch; Merge.TargetDirty = targets.TargetDirty; + Merge.IsCompanyRepo = targets.IsCompanyRepo; } } } @@ -939,6 +943,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit, WorktreeStateLabel, _listWorkingDir); + Merge.PullRequestUrl = entity.Worktree?.PullRequestUrl; } catch { /* best-effort refresh */ } } @@ -1261,7 +1266,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable { if (Task is null || !_worker.IsConnected) return; var taskId = Task.Id; - using var op = ApproveOp.Begin(Loc.T("ops.merge.merging")); + // Company repo: approve = verify + push + draft PR; nothing merges locally. + var company = Merge.IsCompanyRepo; + using var op = ApproveOp.Begin(Loc.T(company ? "ops.merge.publishing" : "ops.merge.merging")); // Subscribed only for the duration of the call: the worker broadcasts to every // client, and a VM left on this event would keep reacting to merges for other tasks. void OnOperationProgress(string opKey, string phase, int current, int total) @@ -1276,7 +1283,14 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable try { var hasChildren = Subtasks.Count > 0 || ChildOutcomes.Count > 0; - var result = await _worker.ApproveReviewAsync(taskId, Merge.SelectedMergeTarget ?? ""); + var result = await _worker.ApproveReviewAsync(taskId, company ? "" : Merge.SelectedMergeTarget ?? ""); + + if (company) + { + if (result?.PullRequestUrl is { } url) Merge.PullRequestUrl = url; + else if (result?.Status == "verify_failed") ErrorReported?.Invoke(result.ErrorMessage ?? Loc.T("vm.detailsIsland.verifyFailed")); + return; + } // Nothing landed -- the target working dir has uncommitted changes (often another // session's WIP in the shared main checkout). Offer the one-shot stash retry; git @@ -1304,7 +1318,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable } catch (Exception ex) { - if (Dialogs != null) + // Company-repo failures (no PAT, push rejected, "gepusht, PR fehlgeschlagen") go to the footer strip. + if (company) ErrorReported?.Invoke(ex.Message); + else if (Dialogs != null) await Dialogs.ShowErrorAsync(ex.Message); } finally diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs index cf3f6d16..3750cdd1 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs @@ -32,6 +32,22 @@ public sealed partial class MergeSectionViewModel : ViewModelBase // dir is visible before anyone clicks, not just after the merge modal refuses it. [ObservableProperty] private bool _targetDirty; + /// <summary>Mirrors MergeTargetsDto.IsCompanyRepo: approve pushes + opens a draft PR, there is no target to pick.</summary> + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowApproveMerge), nameof(ShowCreatePr), nameof(ShowUpdatePr))] + private bool _isCompanyRepo; + + /// <summary>The worktree's draft PR (company repo), once an approve created it.</summary> + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowCreatePr), nameof(ShowUpdatePr), nameof(HasPullRequest))] + [NotifyCanExecuteChangedFor(nameof(OpenPullRequestCommand))] + private string? _pullRequestUrl; + + public bool ShowApproveMerge => !IsCompanyRepo; + public bool ShowCreatePr => IsCompanyRepo && PullRequestUrl is null; + public bool ShowUpdatePr => IsCompanyRepo && PullRequestUrl is not null; + public bool HasPullRequest => PullRequestUrl is not null; + // No CanExecute to gate here — RefreshMergePreviewAsync is triggered from the Bind path, // not a [RelayCommand], so PreviewOp only drives the indicator, never locks a button. public OperationStatus PreviewOp { get; } = new(); @@ -146,11 +162,20 @@ public sealed partial class MergeSectionViewModel : ViewModelBase MergeIsClean = false; MergeIsConflict = false; TargetDirty = false; + IsCompanyRepo = false; + PullRequestUrl = null; SyncWorktree(null, null, null, null, null); SyncTaskContext(null, null, false); SyncChildOutcomes(false, 0); } + [RelayCommand(CanExecute = nameof(HasPullRequest))] + private void OpenPullRequest() + { + var (ok, error) = ShellOpen.Url(PullRequestUrl); + if (!ok && error is not null) ErrorReported?.Invoke(error); + } + [RelayCommand(CanExecute = nameof(CanReviewDiff))] private async System.Threading.Tasks.Task ReviewCombinedDiffAsync() { diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/TicketSettingsTabViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/TicketSettingsTabViewModel.cs index 785a5c1e..ba8821f5 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/TicketSettingsTabViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/TicketSettingsTabViewModel.cs @@ -40,6 +40,15 @@ public sealed partial class TicketSettingsTabViewModel : ViewModelBase // Never shows the stored token — TicketSettingsDto only ever carries the bool. public string TokenPlaceholder => TokenSet ? Loc.T("settings.tickets.tokenSet") : ""; + /// <summary>Azure DevOps PAT being typed; never shows the stored one.</summary> + [ObservableProperty] private string _azdoToken = ""; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(AzdoTokenPlaceholder))] + private bool _azdoTokenSet; + [ObservableProperty] private string _azdoStatusMessage = ""; + + public string AzdoTokenPlaceholder => AzdoTokenSet ? Loc.T("settings.tickets.tokenSet") : ""; + public ObservableCollection<FallbackListOption> FallbackLists { get; } = new(); [ObservableProperty] private FallbackListOption? _selectedFallbackList; @@ -54,6 +63,7 @@ public sealed partial class TicketSettingsTabViewModel : ViewModelBase public async Task LoadAsync() { + AzdoTokenSet = (await _worker.GetAzureDevOpsSettingsAsync())?.TokenSet ?? false; var dto = await _worker.GetTicketSettingsAsync(); if (dto is null) { @@ -86,6 +96,40 @@ public sealed partial class TicketSettingsTabViewModel : ViewModelBase } if (_fallbackLoaded) await _worker.SetTicketFallbackListAsync(SelectedFallbackList?.Id); + if (!string.IsNullOrWhiteSpace(AzdoToken)) + { + await _worker.SetAzureDevOpsTokenAsync(AzdoToken); + AzdoToken = ""; + AzdoTokenSet = true; + } + } + + [RelayCommand] + private async Task TestAzdoConnection() + { + IsBusy = true; + AzdoStatusMessage = ""; + try + { + await SaveAsync(); + var result = await _worker.TestAzureDevOpsConnectionAsync(); + AzdoStatusMessage = result is { Ok: true } + ? Loc.T("vm.tickets.azdoConnectionOk", result.Repo!, result.DefaultBranch!) + : result?.Error ?? Loc.T("vm.tickets.workerOffline"); + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + private async Task ClearAzdoToken() + { + await _worker.ClearAzureDevOpsTokenAsync(); + AzdoToken = ""; + AzdoTokenSet = false; + AzdoStatusMessage = Loc.T("vm.tickets.tokenCleared"); } [RelayCommand] diff --git a/src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml b/src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml index 91918844..362af2fb 100644 --- a/src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml @@ -321,8 +321,18 @@ <TextBlock Classes="diff-del" Text="{Binding DiffDelText}" /> </StackPanel> + <!-- Company repo: no local target — approve pushes and opens a draft PR. --> + <StackPanel Spacing="6" IsVisible="{Binding Merge.IsCompanyRepo}"> + <TextBlock Classes="meta" Text="{loc:Tr details.workConsole.companyTargetHint}" TextWrapping="Wrap" /> + <Button Classes="btn" HorizontalAlignment="Left" + Content="{loc:Tr details.workConsole.openPr}" + ToolTip.Tip="{Binding Merge.PullRequestUrl}" + Command="{Binding Merge.OpenPullRequestCommand}" + IsVisible="{Binding Merge.HasPullRequest}" /> + </StackPanel> + <!-- Target branch + pre-flight mergeability --> - <StackPanel Spacing="4"> + <StackPanel Spacing="4" IsVisible="{Binding Merge.ShowApproveMerge}"> <TextBlock Classes="field-label" Text="{loc:Tr details.workConsole.targetBranch}" /> <ComboBox ItemsSource="{Binding Merge.MergeTargetBranches}" SelectedItem="{Binding Merge.SelectedMergeTarget, Mode=TwoWay}" @@ -333,7 +343,8 @@ IsVisible="{Binding Merge.TargetDirty}" /> </StackPanel> - <StackPanel Spacing="0"> + <!-- The preview is against the local target, meaningless when the PR targets origin. --> + <StackPanel Spacing="0" IsVisible="{Binding Merge.ShowApproveMerge}"> <TextBlock Classes="meta" Text="{Binding Merge.MergePreviewText}" TextWrapping="Wrap" Foreground="{DynamicResource MossBrush}" IsVisible="{Binding Merge.MergeIsClean}" /> @@ -400,8 +411,13 @@ <WrapPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}" Margin="0,0,8,8"> - <Button Classes="btn accent" Content="{loc:Tr details.workConsole.approveMerge}" - Command="{Binding ApproveReviewCommand}" /> + <Button Classes="btn accent" Command="{Binding ApproveReviewCommand}"> + <Panel> + <TextBlock Text="{loc:Tr details.workConsole.approveMerge}" IsVisible="{Binding Merge.ShowApproveMerge}" /> + <TextBlock Text="{loc:Tr details.workConsole.createDraftPr}" IsVisible="{Binding Merge.ShowCreatePr}" /> + <TextBlock Text="{loc:Tr details.workConsole.updatePr}" IsVisible="{Binding Merge.ShowUpdatePr}" /> + </Panel> + </Button> <controls:OperationIndicator Status="{Binding ApproveOp}" /> </StackPanel> <StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}" Margin="0,0,8,8"> diff --git a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml index c1b734d2..7d28cf1a 100644 --- a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml @@ -738,12 +738,14 @@ </ScrollViewer> </TabItem> - <!-- Ticketsystem: Base-URL + PAT for the Bandel ticket integration. Empty base URL - disables the feature entirely, so this tab intentionally never forces a value. --> + <!-- External services: Bandel ticket system (base URL + PAT; empty base URL disables it, so + this tab never forces a value) and the Azure DevOps PAT for company-repo push + draft PR. --> <TabItem Header="{loc:Tr settings.sidebar.categoryTickets}"> <ScrollViewer> <StackPanel Spacing="14" Margin="0,8,0,0"> + <TextBlock Classes="section-label" Text="{loc:Tr settings.tickets.ticketSection}"/> + <StackPanel Spacing="4"> <TextBlock Classes="field-label" Text="{loc:Tr settings.tickets.baseUrl}"/> <TextBox Text="{Binding Tickets.ApiBaseUrl, Mode=TwoWay}"/> @@ -785,6 +787,33 @@ <TextBlock Classes="meta" Text="{loc:Tr settings.tickets.fallbackListHint}" TextWrapping="Wrap" Opacity="0.6"/> </StackPanel> + + <Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0" Margin="0,8,0,0"/> + + <TextBlock Classes="section-label" Text="{loc:Tr settings.tickets.azdoSection}"/> + + <StackPanel Spacing="4"> + <TextBlock Classes="field-label" Text="{loc:Tr settings.tickets.azdoToken}"/> + <TextBox Text="{Binding Tickets.AzdoToken, Mode=TwoWay}" + PasswordChar="•" + PlaceholderText="{Binding Tickets.AzdoTokenPlaceholder}"/> + <TextBlock Classes="meta" Text="{loc:Tr settings.tickets.azdoHint}" + TextWrapping="Wrap" Opacity="0.6"/> + </StackPanel> + + <StackPanel Orientation="Horizontal" Spacing="8"> + <Button Classes="btn" + Content="{loc:Tr settings.tickets.testConnection}" + Command="{Binding Tickets.TestAzdoConnectionCommand}" + IsEnabled="{Binding !Tickets.IsBusy}"/> + <Button Classes="btn danger" + Content="{loc:Tr settings.tickets.clearToken}" + Command="{Binding Tickets.ClearAzdoTokenCommand}" + IsEnabled="{Binding !Tickets.IsBusy}"/> + </StackPanel> + + <TextBlock Classes="meta" Text="{Binding Tickets.AzdoStatusMessage}" TextWrapping="Wrap" + IsVisible="{Binding Tickets.AzdoStatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> </StackPanel> </ScrollViewer> </TabItem> diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/MergeSectionCompanyRepoTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/MergeSectionCompanyRepoTests.cs new file mode 100644 index 00000000..b85cb2d1 --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/MergeSectionCompanyRepoTests.cs @@ -0,0 +1,46 @@ +using ClaudeDo.Ui.ViewModels.Islands; +using Xunit; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +/// <summary>Detail pane in a company repo: approve becomes "create draft PR" / "update PR", and the PR link only exists once there is a PR.</summary> +public sealed class MergeSectionCompanyRepoTests +{ + private sealed class Worker : StubWorkerClient { } + + private static MergeSectionViewModel Vm() => new(new Worker(), services: null!); + + [Fact] + public void PrivateRepo_ShowsOnlyApproveAndMerge() + { + var vm = Vm(); + + Assert.True(vm.ShowApproveMerge); + Assert.False(vm.ShowCreatePr); + Assert.False(vm.ShowUpdatePr); + Assert.False(vm.OpenPullRequestCommand.CanExecute(null)); + } + + [Fact] + public void CompanyRepo_WithoutPr_OffersCreateDraftPr() + { + var vm = Vm(); + vm.IsCompanyRepo = true; + + Assert.False(vm.ShowApproveMerge); + Assert.True(vm.ShowCreatePr); + Assert.False(vm.ShowUpdatePr); + } + + [Fact] + public void CompanyRepo_WithPr_OffersUpdateAndLink() + { + var vm = Vm(); + vm.IsCompanyRepo = true; + vm.PullRequestUrl = "https://srv/Coll/Proj/_git/Repo/pullrequest/5"; + + Assert.False(vm.ShowCreatePr); + Assert.True(vm.ShowUpdatePr); + Assert.True(vm.OpenPullRequestCommand.CanExecute(null)); + } +} diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/TicketSettingsTabViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/TicketSettingsTabViewModelTests.cs index 168a1650..3f7f9e6c 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/TicketSettingsTabViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/TicketSettingsTabViewModelTests.cs @@ -34,6 +34,44 @@ public sealed class TicketSettingsTabViewModelTests : IDisposable public List<string?> SavedFallbacks { get; } = new(); public override Task<TicketSettingsDto?> GetTicketSettingsAsync() => Task.FromResult(Settings); public override Task SetTicketFallbackListAsync(string? listId) { SavedFallbacks.Add(listId); return Task.CompletedTask; } + public List<string> SavedAzdoTokens { get; } = new(); + public AzureDevOpsConnectionDto? AzdoConnection { get; set; } + public override Task<AzureDevOpsSettingsDto?> GetAzureDevOpsSettingsAsync() => Task.FromResult<AzureDevOpsSettingsDto?>(new(false)); + public override Task SetAzureDevOpsTokenAsync(string token) { SavedAzdoTokens.Add(token); return Task.CompletedTask; } + public override Task<AzureDevOpsConnectionDto?> TestAzureDevOpsConnectionAsync() => Task.FromResult(AzdoConnection); + } + + [Fact] + public async Task Save_writes_a_typed_azdo_token_once_and_never_keeps_it_in_the_field() + { + var worker = new Worker { Settings = new TicketSettingsDto("http://api", true, null) }; + var vm = new TicketSettingsTabViewModel(worker, new TestDbFactory(NewContext)); + await vm.LoadAsync(); + Assert.False(vm.AzdoTokenSet); + + vm.AzdoToken = "azdo-pat"; + await vm.SaveAsync(); + await vm.SaveAsync(); + + Assert.Equal(["azdo-pat"], worker.SavedAzdoTokens); + Assert.Equal("", vm.AzdoToken); + Assert.True(vm.AzdoTokenSet); + } + + [Fact] + public async Task Azdo_test_reports_the_server_error_verbatim() + { + var worker = new Worker + { + Settings = new TicketSettingsDto("http://api", true, null), + AzdoConnection = new AzureDevOpsConnectionDto(false, null, null, "Azure DevOps 401: PAT abgelaufen"), + }; + var vm = new TicketSettingsTabViewModel(worker, new TestDbFactory(NewContext)); + await vm.LoadAsync(); + + await vm.TestAzdoConnectionCommand.ExecuteAsync(null); + + Assert.Equal("Azure DevOps 401: PAT abgelaufen", vm.AzdoStatusMessage); } [Fact] From 0e6a48e26866378273bf495cf333bbcef6b4f4d3 Mon Sep 17 00:00:00 2001 From: mika kuns <mika.kuns@fb-tuning.de> Date: Thu, 1 Oct 2026 12:15:20 +0200 Subject: [PATCH 09/12] feat(prompts): merge helper knows company-repo approve ends in a draft PR --- src/ClaudeDo.Data/PromptFiles.cs | 6 +++++- tests/ClaudeDo.Data.Tests/PromptFilesTests.cs | 10 ++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/ClaudeDo.Data/PromptFiles.cs b/src/ClaudeDo.Data/PromptFiles.cs index 5024a539..ee6b01f5 100644 --- a/src/ClaudeDo.Data/PromptFiles.cs +++ b/src/ClaudeDo.Data/PromptFiles.cs @@ -552,6 +552,10 @@ public static class PromptFiles - Prefer the MCP tools whenever they apply. Only if the MCP tools cannot reach an in-progress merge may you finish it by hand: resolve the markers, then `git add -- <the resolved paths>` and `git commit` — NEVER `git add -A` or a bare commit, because the checkout is shared with other sessions. - Never use raw `git merge`, `git reset`, or `git checkout` to force a merge. Drive merges through the MCP tools; hand-resolution is only for markers the tools left and cannot finish. + ## Company repos (Azure DevOps) + + If the list's repo is a company repo (origin on the Azure DevOps Server), approve does not merge anything locally: review_task approve runs the verify command in the task's own worktree, pushes its branch and opens a draft PR (or reuses the one already open for that branch). mergeStatus is "pr_created" or "pr_updated" and mergeMessage is the PR URL; the task deliberately stays WaitingForReview until the user merges the PR on Azure DevOps and ticks it Done. There are no merge conflicts to resolve and main does not move, so skip preview_merge_set's merge-order reasoning, do NOT call verify_merges (the verify already ran inside approve, skipVerify is ignored there), and treat "erst mergen, dann neu starten" as harmless. A blocked result names what failed (no Azure DevOps PAT, push rejected, or "gepusht, PR fehlgeschlagen: …" when the push landed but the PR call did not) — report it, do not retry blindly. In the Phase 5 summary, list every PR link. + ## Verify once — after the last merge When every merge above is done (including conflict resolutions), call verify_merges with the ids of ALL tasks that reported merged_verify_pending. It runs the list's verify command a single time and promotes them all to Done on success — that is the per-merge gate you skipped, paid once. @@ -600,7 +604,7 @@ public static class PromptFiles ## Phase 5 — Summary Print one line per task from the brief: - title — final status — merge commit (if any) — conflicts resolved (if any) — verify-failed (yes/no). + title — final status — merge commit or PR link (if any) — conflicts resolved (if any) — verify-failed (yes/no). Then list anything you skipped or left for the user and why, and any follow-ups worth turning into new tasks. """; diff --git a/tests/ClaudeDo.Data.Tests/PromptFilesTests.cs b/tests/ClaudeDo.Data.Tests/PromptFilesTests.cs index ff3d7362..79540936 100644 --- a/tests/ClaudeDo.Data.Tests/PromptFilesTests.cs +++ b/tests/ClaudeDo.Data.Tests/PromptFilesTests.cs @@ -212,6 +212,16 @@ public class PromptFilesTests Assert.Contains("Never use raw `git merge`", d); } + [Fact] + public void DefaultFor_merge_helper_merge_explains_company_repo_draft_prs() + { + var d = PromptFiles.DefaultFor(PromptKind.MergeHelperMerge); + Assert.Contains("pr_created", d); + Assert.Contains("pr_updated", d); + Assert.Contains("do NOT call verify_merges", d); + Assert.Contains("list every PR link", d); + } + [Fact] public void DefaultFor_merge_helper_merge_runs_all_merges_before_any_rerun() { From 674db27f6e1fdbe16cd77a4673677333493c4327 Mon Sep 17 00:00:00 2001 From: mika kuns <mika.kuns@fb-tuning.de> Date: Thu, 1 Oct 2026 12:18:06 +0200 Subject: [PATCH 10/12] fix(worker): keep Git Credential Manager from prompting during company fetch/push --- src/ClaudeDo.Worker/AzureDevOps/AzureDevOpsClient.cs | 5 +++-- .../AzureDevOps/AzureDevOpsClientTests.cs | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ClaudeDo.Worker/AzureDevOps/AzureDevOpsClient.cs b/src/ClaudeDo.Worker/AzureDevOps/AzureDevOpsClient.cs index a852fd2c..841d3aea 100644 --- a/src/ClaudeDo.Worker/AzureDevOps/AzureDevOpsClient.cs +++ b/src/ClaudeDo.Worker/AzureDevOps/AzureDevOpsClient.cs @@ -56,12 +56,13 @@ public sealed class AzureDevOpsClient public static string AuthHeaderValue(string pat) => "Basic " + Convert.ToBase64String(Encoding.ASCII.GetBytes(":" + pat)); /// <summary> - /// Environment for a git network call: never prompt (a background process would hang), and hand the PAT + /// Environment for a git network call: never prompt — neither the terminal nor Git Credential Manager's GUI + /// (a background process would hang on either) — and hand the PAT /// over as <c>http.extraHeader</c> via <c>GIT_CONFIG_*</c> so it never shows up in a command line. /// </summary> public static IReadOnlyDictionary<string, string> GitAuthEnv(string? pat) { - var env = new Dictionary<string, string> { ["GIT_TERMINAL_PROMPT"] = "0" }; + var env = new Dictionary<string, string> { ["GIT_TERMINAL_PROMPT"] = "0", ["GCM_INTERACTIVE"] = "never" }; if (!string.IsNullOrEmpty(pat)) { env["GIT_CONFIG_COUNT"] = "1"; diff --git a/tests/ClaudeDo.Worker.Tests/AzureDevOps/AzureDevOpsClientTests.cs b/tests/ClaudeDo.Worker.Tests/AzureDevOps/AzureDevOpsClientTests.cs index 4956d1b7..8bcc54e7 100644 --- a/tests/ClaudeDo.Worker.Tests/AzureDevOps/AzureDevOpsClientTests.cs +++ b/tests/ClaudeDo.Worker.Tests/AzureDevOps/AzureDevOpsClientTests.cs @@ -61,6 +61,7 @@ public sealed class AzureDevOpsClientTests : IDisposable var env = AzureDevOpsClient.GitAuthEnv("geheim"); Assert.Equal("0", env["GIT_TERMINAL_PROMPT"]); + Assert.Equal("never", env["GCM_INTERACTIVE"]); Assert.Equal("1", env["GIT_CONFIG_COUNT"]); Assert.Equal("http.extraHeader", env["GIT_CONFIG_KEY_0"]); Assert.Equal("Authorization: Basic OmdlaGVpbQ==", env["GIT_CONFIG_VALUE_0"]); From 93fd6740a18c4a05ad347639a586518a0cef0a74 Mon Sep 17 00:00:00 2001 From: mika kuns <mika.kuns@fb-tuning.de> Date: Thu, 1 Oct 2026 12:18:07 +0200 Subject: [PATCH 11/12] docs(worker): document company mode (fetch, push, draft PR) and its open smoke tests --- docs/explore-notes/review-merge.md | 17 ++++++++++-- docs/open.md | 18 +++++++++++++ src/ClaudeDo.Worker/CLAUDE.md | 43 +++++++++++++++++++++++++++--- 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/docs/explore-notes/review-merge.md b/docs/explore-notes/review-merge.md index e8a47bd0..482c002a 100644 --- a/docs/explore-notes/review-merge.md +++ b/docs/explore-notes/review-merge.md @@ -1,8 +1,8 @@ # Review, merge & conflict resolution > **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative. -> Last verified against `44d94caa` (2026-10-01), which made the parent's worktree the unit -> branch (children fast-forwarded into it). +> Last verified against `0e6a48e2` (2026-10-01): parent's worktree is the unit branch (children +> fast-forwarded into it); in a company repo approve ends in a pushed branch + draft PR. > Planning mode renders per file and `DiffLinesView` is retired. > Drift check: `git log --oneline 20bce9b..HEAD -- src/ClaudeDo.Worker/Lifecycle src/ClaudeDo.Worker/State src/ClaudeDo.Worker/Planning src/ClaudeDo.Ui/ViewModels/Conflicts src/ClaudeDo.Worker/External` > Stable structure only (no line numbers). See docs/explore-notes/README.md. @@ -28,6 +28,19 @@ There is no separate "Merge all" entry. - **Child of a unit** (parent has an `Active` worktree) → `ApproveAndMergeAsync` absorbs it into the unit branch (`UnitBranchService.AbsorbChildAsync`, ff-only) instead of merging to the target. - **No active worktree** (sandbox run) → straight to `Done`. +- **Company repo** (`PullRequestPublisher.IsCompanyRepoAsync`, origin matches + `company_remote_pattern`, checked live) with an `Active` worktree → `ApproveCompanyAsync` + instead of a merge, after the planning-parent gate and the child absorb: missing PAT / non-ADO + remote is `blocked` before anything runs, then the verify command in the **worktree** + (`skipVerify` ignored — no local merge to defer it to; red = `verify_failed`, nothing pushed), + then `PullRequestPublisher.PublishAsync`: `git push -u origin <branch>` (PAT via `GIT_CONFIG_*` + env, `AzureDevOpsClient.GitAuthEnv`), find-or-create the draft PR, store + `worktrees.pull_request_url`, `WorktreeUpdated`. Result `pr_created` / `pr_updated` + URL + (`MergeResult.PullRequestUrl`; MCP puts it in `mergeMessage`). Task stays `WaitingForReview`, + worktree `Active`, ticket `InBearbeitung`; `Done` is the user's manual toggle after the ADO + merge. A PR failure after a successful push is `blocked` with `gepusht, PR fehlgeschlagen: …`. + ⚠️ Only approve branches — `merge_task`/the merge modal and the legacy-unit orchestrator still + merge locally in a company repo. Review transitions all live in `TaskStateService`: `SubmitForReviewAsync`, `SubmitForChildrenAsync`, `ApproveReviewAsync`, `RejectToQueueAsync`, `RejectToIdleAsync`, diff --git a/docs/open.md b/docs/open.md index 2fad2d0c..2d997e27 100644 --- a/docs/open.md +++ b/docs/open.md @@ -74,6 +74,24 @@ Bleibt manuell zu prüfen (UI-Pfad, nie sichtbar gelaufen): - [ ] Base-URL leeren → Menüeintrag verschwindet, keine Netzwerkaufrufe mehr. - [ ] Sichtprüfung des Settings-Tabs + des Ticket-Projekt-Dropdowns (nie gerendert gesehen). +## Offene Verifikation (2026-10-01, Unit-Branch Teil 3: Firmen-Modus / Draft-PR) + +Build + gefilterte Tests grün (Worker: AzureDevOps/CompanyFetchBase/CompanyApprove/CommitMessageBuilder, +Ui: TicketSettingsTab/MergeSectionCompanyRepo, Data: PromptFiles, Localization). **Nie gegen echtes ADO, +nicht visuell geprüft:** + +- [ ] Sichtprüfung: Settings-Reiter heißt „Externe Dienste", Abschnitt Azure DevOps (PAT, Test, Löschen); + Detail-Pane im Firmen-Repo: kein Ziel-Dropdown, Button „Draft-PR erstellen" → nach dem Approve + „PR aktualisieren" + Button „Pull Request öffnen"; Approve bleibt hinter „Diff öffnen" gegated. +- [ ] PAT eintragen, „Verbindung testen" — meldet Repo + Default-Branch der ersten Firmen-Liste. +- [ ] Ticket-Task in einem echten Firmen-Repo bis zum Draft-PR: Branch `<typ>/<ticket>-<slug>` startet + von frisch gefetchtem `origin/<basis>`, Commit ohne `ClaudeDo-Task:`-Trailer, PR-Titel + `…, Resolves #<id>`, Beschreibung mit Ticket-Link (Projekt-Id-Lookup über `GET /api/ticket/{id}` + ist nur gegen den Stub getestet) und `Geprüft:`-Zeile, Task bleibt WaitingForReview. +- [ ] Zweiter Approve nach Reject mit Feedback → `pr_updated`, derselbe PR bekommt die neuen Commits. +- [ ] Planning-Unit mit 2 Children im Firmen-Repo → ein PR für die ganze Unit. +- [ ] API-Version-Fallback (Server meldet „supports is X") ist nur gegen den Stub getestet. + ## Offene Verifikation (2026-10-01, Ticket-Import über alle Projekte) Build + Unit-Tests grün, **nicht visuell/live verifiziert**: diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md index 09c8cadf..5263ce29 100644 --- a/src/ClaudeDo.Worker/CLAUDE.md +++ b/src/ClaudeDo.Worker/CLAUDE.md @@ -36,6 +36,8 @@ Worker/ Prime/ — "Prime Claude" schedules: PrimeScheduler, PrimeRunner, PrimePrompts, DailyPrepPrompt, PrimeScheduleValidation, NextDueCalculator, PrimeScheduleSignal Online/ — optional Online Inbox sync (off by default; zero network when disabled) + AzureDevOps/ — company-repo push + draft PR: AzureDevOpsClient (REST, port of pr-create.ps1), + PullRequestPublisher (push, find-or-create draft PR, pull_request_url) Tickets/ — Bandel ticket-system integration (per-list import + status write-back), inactive without ticket_api_base_url: TicketSystemConfig/TicketClientFactory, BandelTicketClient, TicketDtos, TicketStatusMap, TicketStatusSync, @@ -151,8 +153,8 @@ Full flow, invariants, and model/effort/max-turns resolution (including the low- - **ClaudeProcess** — spawns `claude -p --output-format stream-json --verbose --permission-mode auto` (or whatever app settings specify). Prompt via stdin, NDJSON from stdout. CancellationToken kills the process tree. Resolves `_cfg.ClaudeBin` via `ExecutableResolver` (`ClaudeDo.Data`, shared with `ClaudeCliPreflight` and the Installer's checks) before spawning — a `.cmd`/`.bat` shim (e.g. an npm-installed `claude`) is launched through `cmd.exe /c` since `UseShellExecute = false` can't exec a shim directly; a resolved `.exe` starts exactly as before. Throws if nothing resolves. *(Not yet on `main` — see `Environment Checks` in `ClaudeDo.Installer/CLAUDE.md`.)* - **ClaudeArgsBuilder** — `--model`, `--effort`, `--max-turns`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume` - **StreamAnalyzer** — parses NDJSON; extracts session_id, token counts, turn counts, result text, structured output. Replaced MessageParser. -- **WorktreeManager** — worktrees on `<typ>/<nummer>-<slug>` branches, same leaf as the folder (`NameFor`, slug capped at 40 chars, umlauts transliterated). Typ from `BranchTypeFor` (`feat`→`feature`, `fix/refactor/docs/test/chore` as-is, anything else `chore`). Nummer = ticket id (own `TicketRef` or the parent's), else the task number — in a company repo (`CompanyRepo`, origin matches `company_remote_pattern`) without a ticket no number at all. A taken folder/branch name gets an 8-char task-id suffix, so the self-heal never force-deletes a foreign branch. Base = `rev-parse <unit branch>` for a child whose parent has an Active worktree, else `list_config.base_branch`, else the checked-out HEAD. Commits with semantic messages, updates DB with head commit + diff stats -- **CommitMessageBuilder** — `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId`; `BuildMerge` is the merge-commit variant (`{commitType}(slug): merge title` + trailer). **Every merge caller passes a blank commit message on purpose** — `TaskMergeService` fills in `BuildMerge` from the task's commit type and its list's name, which is the only place that knows both. Don't reintroduce a caller-side literal. +- **WorktreeManager** — worktrees on `<typ>/<nummer>-<slug>` branches, same leaf as the folder (`NameFor`, slug capped at 40 chars, umlauts transliterated). Typ from `BranchTypeFor` (`feat`→`feature`, `fix/refactor/docs/test/chore` as-is, anything else `chore`). Nummer = ticket id (own `TicketRef` or the parent's), else the task number — in a company repo (`CompanyRepo`, origin matches `company_remote_pattern`) without a ticket no number at all. A taken folder/branch name gets an 8-char task-id suffix, so the self-heal never force-deletes a foreign branch. Base = `rev-parse <unit branch>` for a child whose parent has an Active worktree, else `list_config.base_branch`, else the checked-out HEAD — in a company repo a unit root first fetches its basis (see Azure DevOps below). Commits with semantic messages, updates DB with head commit + diff stats +- **CommitMessageBuilder** — `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId` (no trailer in company repos — the convention forbids signature trailers and nothing reads it); `BuildMerge` is the merge-commit variant (`{commitType}(slug): merge title` + trailer). **Every merge caller passes a blank commit message on purpose** — `TaskMergeService` fills in `BuildMerge` from the task's commit type and its list's name, which is the only place that knows both. Don't reintroduce a caller-side literal. - **TaskResetService** — discards a failed task's worktree, resets the row to Idle, preserves run history - **AgentFileService** — manages `~/.claudeDo/agents/*.md`; list/refresh via SignalR - **LogWriter** — async StreamWriter wrapper, auto-creates parent dirs @@ -273,10 +275,10 @@ non-obvious, behaviour-changing. A fixed bug is git history, not a finding. - `db_path`, `sandbox_root`, `log_root` - `worktree_root_strategy` (`sibling` | `central`), `central_worktree_root` - `queue_backstop_interval_ms` (30000) — also the gate/throttle recovery timer -- `company_remote_pattern` (`/_git/`) — Teilstring der origin-URL, an dem `CompanyRepo` ein Firmen-Repo erkennt; nur per Hand editierbar. +- `company_remote_pattern` (`/_git/`) — Teilstring der origin-URL, an dem `CompanyRepo` ein Firmen-Repo erkennt; nur per Hand editierbar. Schaltet Fetch vor dem Unit-Root, Approve = Push + Draft-PR und Commits ohne Trailer (siehe Azure DevOps). - `signalr_port` (47821), `claude_bin`, `ticket_api_base_url` — the two UI-writable fields (`claude_bin`: Settings → Dateien → CLAUDE CLI, hub `GetClaudeBin`/`SetClaudeBin`; - `ticket_api_base_url`: Settings → Ticketsystem, hub `GetTicketSettings`/`SetTicketApiBaseUrl`). + `ticket_api_base_url`: Settings → Externe Dienste, hub `GetTicketSettings`/`SetTicketApiBaseUrl`). Both edit the DI-singleton `WorkerConfig` in place *and* persist via their own `SaveKey` call (`SaveClaudeBin`/`SaveTicketApiBaseUrl`), so each applies without a restart. Every other field here is hand-edit only. @@ -341,6 +343,39 @@ distinguish the two singletons). The token never touches `worker.config.json` or `TicketStatusMap.NameOf` supplies the canonical name. ⚠️ The base URL must be the **IP** — `bandelapis.fb-tuning.local` still refuses the connection. +## Azure DevOps (company repos) + +Active when origin matches `company_remote_pattern` (live `git remote get-url`, no cache). The PAT +(scope Code Read & Write) is DPAPI-encrypted at `~/.claudeDo/azdo.pat` (DI marker +`AzureDevOpsPatStore`), set via Settings → Externe Dienste (hub `GetAzureDevOpsSettings` / +`SetAzureDevOpsToken` / `ClearAzureDevOpsToken` / `TestAzureDevOpsConnection` — the test reads the +first list whose origin matches and parses). Deliberately independent of the work-conventions +plugin's PAT file. + +- **`AzureDevOpsClient`** — the only HTTP surface. `ParseRemote` splits at `/_git/` (API base = + everything before it, embedded credentials stripped, http(s) only). `GitAuthEnv(pat)` is the + env for every git network call: `GIT_TERMINAL_PROMPT=0`, `GCM_INTERACTIVE=never`, PAT as + `http.extraHeader` via `GIT_CONFIG_COUNT/KEY_0/VALUE_0` — never on a command line. API version + starts at 6.0 and retries once with the version an out-of-range error names. ⚠️ A rejected PAT + is HTTP **203** + an HTML login page, which `IsSuccessStatusCode` counts as success — handled + explicitly. +- **Fetch before a unit root** (`WorktreeManager.FetchCompanyBaseAsync`, once per unit root, never + per child): basis = `base_branch` → ADO `defaultBranch` → `origin/HEAD`; `git fetch origin + +refs/heads/<b>:refs/remotes/origin/<b>`; the worktree starts from `origin/<b>`. Local `<b>` is + pulled along best-effort: not checked out → `git fetch . origin/<b>:<b>` (no network, git + refuses a non-ff), checked out + clean → `merge --ff-only`, dirty/diverged → warning only. A + failed fetch warns and continues from the local branch — it never fails the task. +- **Approve** → `TaskMergeService.ApproveCompanyAsync` → `PullRequestPublisher`; details in + [review-merge](../../docs/explore-notes/review-merge.md). PR title + `<type>(<list-slug>): <title>, Resolves #<ticket>`; description + `Resolves [#id](https://ticket.fb-tuning.local/board/project/<p>/ticket/<id>)` (project id = walk + `GET /api/ticket/{id}` up until `parentTicketID` is null — a ticket's parent is its project, a + task's parent is a ticket, live-checked 2026-10-01; unknown → plain `Resolves #id`), the task + description, `Geprüft: …` with only what actually ran. Target = `base_branch`, else the ADO + default branch. +- Not done on purpose: PR status polling / auto-`Done`, other PR providers, worktree cleanup on + `Done`, fetch in private repos, a UI for `company_remote_pattern`. + ## Notes - The worker runs standalone — start it separately from the UI. Loopback only (127.0.0.1). From 4917533cb4d5440833837bb0b52fe51260ed9992 Mon Sep 17 00:00:00 2001 From: mika kuns <mika.kuns@fb-tuning.de> Date: Thu, 1 Oct 2026 12:25:15 +0200 Subject: [PATCH 12/12] fix(worker): kept worktree in a company repo opens a PR, unexpected ADO/git errors never fail a task start --- docs/explore-notes/review-merge.md | 2 +- .../Repositories/WorktreeRepository.cs | 4 ++-- .../AzureDevOps/AzureDevOpsClient.cs | 13 +++++++++---- .../AzureDevOps/PullRequestPublisher.cs | 3 ++- .../Lifecycle/TaskMergeService.cs | 14 ++++++++++++-- src/ClaudeDo.Worker/Runner/WorktreeManager.cs | 15 ++++++++++++--- .../AzureDevOps/CompanyApproveTests.cs | 16 ++++++++++++++++ 7 files changed, 54 insertions(+), 13 deletions(-) diff --git a/docs/explore-notes/review-merge.md b/docs/explore-notes/review-merge.md index 482c002a..660e6658 100644 --- a/docs/explore-notes/review-merge.md +++ b/docs/explore-notes/review-merge.md @@ -29,7 +29,7 @@ There is no separate "Merge all" entry. the unit branch (`UnitBranchService.AbsorbChildAsync`, ff-only) instead of merging to the target. - **No active worktree** (sandbox run) → straight to `Done`. - **Company repo** (`PullRequestPublisher.IsCompanyRepoAsync`, origin matches - `company_remote_pattern`, checked live) with an `Active` worktree → `ApproveCompanyAsync` + `company_remote_pattern`, checked live) with an `Active` or `Kept` worktree → `ApproveCompanyAsync` instead of a merge, after the planning-parent gate and the child absorb: missing PAT / non-ADO remote is `blocked` before anything runs, then the verify command in the **worktree** (`skipVerify` ignored — no local merge to defer it to; red = `verify_failed`, nothing pushed), diff --git a/src/ClaudeDo.Data/Repositories/WorktreeRepository.cs b/src/ClaudeDo.Data/Repositories/WorktreeRepository.cs index 5064820d..394e905c 100644 --- a/src/ClaudeDo.Data/Repositories/WorktreeRepository.cs +++ b/src/ClaudeDo.Data/Repositories/WorktreeRepository.cs @@ -45,8 +45,6 @@ public sealed class WorktreeRepository .SetProperty(w => w.MergeCommit, mergeCommit), ct); } - /// <summary>Records a successful auto-rebase: the branch's fork point moves to the rebased-onto - /// commit and its head moves to the new tip <c>git rebase</c> produced.</summary> /// <summary>Stores the draft PR an approve in a company repo created or reused.</summary> public async Task SetPullRequestUrlAsync(string taskId, string url, CancellationToken ct = default) { @@ -55,6 +53,8 @@ public sealed class WorktreeRepository .ExecuteUpdateAsync(s => s.SetProperty(w => w.PullRequestUrl, url), ct); } + /// <summary>Records a successful auto-rebase: the branch's fork point moves to the rebased-onto + /// commit and its head moves to the new tip <c>git rebase</c> produced.</summary> public async Task SetRebasedAsync(string taskId, string baseCommit, string headCommit, CancellationToken ct = default) { await _context.Worktrees diff --git a/src/ClaudeDo.Worker/AzureDevOps/AzureDevOpsClient.cs b/src/ClaudeDo.Worker/AzureDevOps/AzureDevOpsClient.cs index 841d3aea..91c7cafa 100644 --- a/src/ClaudeDo.Worker/AzureDevOps/AzureDevOpsClient.cs +++ b/src/ClaudeDo.Worker/AzureDevOps/AzureDevOpsClient.cs @@ -121,7 +121,7 @@ public sealed class AzureDevOpsClient private static string StripHeads(string branch) => branch.StartsWith("refs/heads/", StringComparison.Ordinal) ? branch["refs/heads/".Length..] : branch; - private async Task<JsonNode> SendAsync( + private async Task<JsonObject> SendAsync( AzdoRepo repo, HttpMethod method, string path, string? query, string? body, CancellationToken ct) { var pat = Pat; @@ -153,7 +153,11 @@ public sealed class AzureDevOpsClient throw new AzureDevOpsException("Azure DevOps 203: PAT abgelaufen oder ohne Scope Code (Read & Write)."); if (response.IsSuccessStatusCode) { - try { return JsonNode.Parse(payload) ?? new JsonObject(); } + try + { + return JsonNode.Parse(payload) as JsonObject + ?? throw new AzureDevOpsException($"Unerwartete Antwort von Azure DevOps bei {method} {path}."); + } catch (JsonException ex) { throw new AzureDevOpsException($"Unerwartete Antwort von Azure DevOps: {ex.Message}"); } } @@ -174,9 +178,10 @@ public sealed class AzureDevOpsClient { try { - if (JsonNode.Parse(payload)?["message"]?.GetValue<string>() is { Length: > 0 } msg) return msg; + if (JsonNode.Parse(payload) is JsonObject o && o["message"]?.GetValue<string>() is { Length: > 0 } msg) return msg; } - catch (JsonException) { } + // InvalidOperationException: "message" exists but is not a string. + catch (Exception ex) when (ex is JsonException or InvalidOperationException) { } return payload.Length <= 500 ? payload : payload[..500]; } } diff --git a/src/ClaudeDo.Worker/AzureDevOps/PullRequestPublisher.cs b/src/ClaudeDo.Worker/AzureDevOps/PullRequestPublisher.cs index 02277496..105c241d 100644 --- a/src/ClaudeDo.Worker/AzureDevOps/PullRequestPublisher.cs +++ b/src/ClaudeDo.Worker/AzureDevOps/PullRequestPublisher.cs @@ -83,7 +83,8 @@ public sealed class PullRequestPublisher var description = BuildDescription(task, ticketId, await ResolveProjectIdAsync(ticketId, ct), verifyCommand); (url, created) = await _azdo.EnsureDraftPullRequestAsync(repo, wt.BranchName, target, title, description, ct); } - catch (AzureDevOpsException ex) + // InvalidOperationException: a field of the ADO answer has an unexpected JSON type. + catch (Exception ex) when (ex is AzureDevOpsException or InvalidOperationException) { return Blocked($"gepusht, PR fehlgeschlagen: {ex.Message}"); } diff --git a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs index efa2662b..e460f55b 100644 --- a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs +++ b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs @@ -1054,7 +1054,8 @@ public sealed class TaskMergeService } } - if (wt is { State: WorktreeState.Active } && _pullRequests is not null + // Kept (parked or reverted) goes the PR way too: a local merge has no place in a company repo. + if (wt is { State: WorktreeState.Active or WorktreeState.Kept } && _pullRequests is not null && !string.IsNullOrWhiteSpace(list.WorkingDir) && await _pullRequests.IsCompanyRepoAsync(list.WorkingDir, ct)) return await ApproveCompanyAsync(task, list, wt, verifyCommand, ct, progress); @@ -1164,9 +1165,18 @@ public sealed class TaskMergeService if (failed is not null) return failed; } - return await ProgressReporter.RunAsync( + var result = await ProgressReporter.RunAsync( _pullRequests.PublishAsync(task, list, wt, ranVerify ? verifyCommand : null, ct), ProgressReportInterval, progress, "push + draft PR running"); + + // A branch with an open PR is under review again, whatever parked it before. + if (wt.State == WorktreeState.Kept && result.PullRequestUrl is not null) + { + using (var ctx = _dbFactory.CreateDbContext()) + await new WorktreeRepository(ctx).SetStateAsync(task.Id, WorktreeState.Active, ct); + await _broadcaster.WorktreeUpdated(task.Id); + } + return result; } /// <summary> diff --git a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs index 3f446b22..84928d69 100644 --- a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs +++ b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs @@ -282,8 +282,17 @@ public sealed class WorktreeManager .Select(c => c.BaseBranch) .FirstOrDefaultAsync(ct)).NullIfBlank(); - if (isCompany && await FetchCompanyBaseAsync(workingDir, baseBranch, ct) is { } fetched) - return fetched; + if (isCompany) + { + try + { + if (await FetchCompanyBaseAsync(workingDir, baseBranch, ct) is { } fetched) return fetched; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Fetching the company base in {working_dir} failed, continuing with the local branch", workingDir); + } + } return baseBranch is null ? await _git.RevParseHeadAsync(workingDir, ct) @@ -304,7 +313,7 @@ public sealed class WorktreeManager if (basis is null && _azdo is not null && pat is not null && AzureDevOpsClient.ParseRemote(remote) is { } repo) { try { basis = await _azdo.GetDefaultBranchAsync(repo, ct); } - catch (AzureDevOpsException ex) { _logger.LogWarning("Default branch from Azure DevOps unavailable: {reason}", ex.Message); } + catch (Exception ex) when (ex is AzureDevOpsException or InvalidOperationException) { _logger.LogWarning("Default branch from Azure DevOps unavailable: {reason}", ex.Message); } } basis ??= await _git.GetRemoteHeadBranchAsync(workingDir, ct); if (basis is null) diff --git a/tests/ClaudeDo.Worker.Tests/AzureDevOps/CompanyApproveTests.cs b/tests/ClaudeDo.Worker.Tests/AzureDevOps/CompanyApproveTests.cs index 50fb250d..2b405613 100644 --- a/tests/ClaudeDo.Worker.Tests/AzureDevOps/CompanyApproveTests.cs +++ b/tests/ClaudeDo.Worker.Tests/AzureDevOps/CompanyApproveTests.cs @@ -158,6 +158,22 @@ public sealed class CompanyApproveTests : IDisposable Assert.StartsWith("Resolves [#412](https://ticket.fb-tuning.local/board/project/393/ticket/412)", description); } + [Fact] + public async Task Approve_KeptWorktree_OpensPrInsteadOfMergingLocally() + { + if (!GitRepoFixture.IsGitAvailable()) return; + await SetUpWorktree(); + using (var ctx = _db.CreateContext()) + await new WorktreeRepository(ctx).SetStateAsync("t1", WorktreeState.Kept); + var mainBefore = GitRepoFixture.RunGit(_repo.RepoDir, "rev-parse", "main").Trim(); + + var r = await Merge(Ado()).ApproveAndMergeAsync("t1", "", default); + + Assert.Equal(TaskMergeService.StatusPrCreated, r.Status); + Assert.Equal(mainBefore, GitRepoFixture.RunGit(_repo.RepoDir, "rev-parse", "main").Trim()); + Assert.Equal(WorktreeState.Active, (await Rows()).Wt.State); + } + [Fact] public async Task Approve_ReusesActivePr_AsUpdate() {