From 3fe1feadf9e8005f00c02c0cc5fc3736a4d1392c Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 1 Oct 2026 10:39:49 +0200 Subject: [PATCH] feat(data): per-list base branch, preserved by every config writer --- .../ListConfigEntityConfiguration.cs | 1 + ...261001083902_AddListBaseBranch.Designer.cs | 1164 +++++++++++++++++ .../20261001083902_AddListBaseBranch.cs | 28 + .../ClaudeDoDbContextModelSnapshot.cs | 4 + src/ClaudeDo.Data/Models/ListConfigEntity.cs | 6 + .../Repositories/ListRepository.cs | 1 + src/ClaudeDo.Data/Wire.cs | 5 +- .../External/ConfigMcpTools.cs | 13 +- src/ClaudeDo.Worker/Hub/WorkerHub.cs | 8 +- .../Hub/ListConfigBaseBranchTests.cs | 100 ++ 10 files changed, 1321 insertions(+), 9 deletions(-) create mode 100644 src/ClaudeDo.Data/Migrations/20261001083902_AddListBaseBranch.Designer.cs create mode 100644 src/ClaudeDo.Data/Migrations/20261001083902_AddListBaseBranch.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Hub/ListConfigBaseBranchTests.cs diff --git a/src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs index c873c5ac..292cdc97 100644 --- a/src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs @@ -21,5 +21,6 @@ public class ListConfigEntityConfiguration : IEntityTypeConfiguration c.PermissionMode).HasColumnName("permission_mode"); builder.Property(c => c.TicketProjectId).HasColumnName("ticket_project_id"); builder.Property(c => c.PowerUseAllowed).HasColumnName("power_use_allowed"); + builder.Property(c => c.BaseBranch).HasColumnName("base_branch"); } } diff --git a/src/ClaudeDo.Data/Migrations/20261001083902_AddListBaseBranch.Designer.cs b/src/ClaudeDo.Data/Migrations/20261001083902_AddListBaseBranch.Designer.cs new file mode 100644 index 00000000..54c8a3e0 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20261001083902_AddListBaseBranch.Designer.cs @@ -0,0 +1,1164 @@ +// +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("20261001083902_AddListBaseBranch")] + partial class AddListBaseBranch + { + /// + 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("Id") + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("AutoContinueOnUsageLimit") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("auto_continue_on_usage_limit"); + + b.Property("CentralWorktreeRoot") + .HasColumnType("TEXT") + .HasColumnName("central_worktree_root"); + + b.Property("DailyPrepMaxTasks") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5) + .HasColumnName("daily_prep_max_tasks"); + + b.Property("DefaultClaudeInstructions") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("") + .HasColumnName("default_claude_instructions"); + + b.Property("DefaultMaxTurns") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(40) + .HasColumnName("default_max_turns"); + + b.Property("DefaultModel") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sonnet") + .HasColumnName("default_model"); + + b.Property("DefaultPermissionMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("bypassPermissions") + .HasColumnName("default_permission_mode"); + + b.Property("ListHandlerMaxHandoffs") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4) + .HasColumnName("list_handler_max_handoffs"); + + b.Property("MaxParallelExecutions") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1) + .HasColumnName("max_parallel_executions"); + + b.Property("MaxTurnsCeiling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(80) + .HasColumnName("max_turns_ceiling"); + + b.Property("ModelPresets") + .HasColumnType("TEXT") + .HasColumnName("model_presets"); + + b.Property("NextTaskNumber") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1) + .HasColumnName("next_task_number"); + + b.Property("PowerUseEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("power_use_enabled"); + + b.Property("PowerUseHandoffPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(95) + .HasColumnName("power_use_handoff_pct"); + + b.Property("PowerUseWindowEnd") + .HasColumnType("TEXT") + .HasColumnName("power_use_window_end"); + + b.Property("PowerUseWindowStart") + .HasColumnType("TEXT") + .HasColumnName("power_use_window_start"); + + b.Property("RepoImportFolders") + .HasColumnType("TEXT") + .HasColumnName("repo_import_folders"); + + b.Property("ReportExcludedPaths") + .HasColumnType("TEXT") + .HasColumnName("report_excluded_paths"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("StandupWeekday") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(3) + .HasColumnName("standup_weekday"); + + b.Property("TicketFallbackListId") + .HasColumnType("TEXT") + .HasColumnName("ticket_fallback_list_id"); + + b.Property("UsageGateFiveHourPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(80) + .HasColumnName("usage_gate_five_hour_pct"); + + b.Property("UsageGateSevenDayPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(90) + .HasColumnName("usage_gate_seven_day_pct"); + + b.Property("UsageThrottleFiveHourHardPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(65) + .HasColumnName("usage_throttle_five_hour_hard_pct"); + + b.Property("UsageThrottleFiveHourSoftPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(50) + .HasColumnName("usage_throttle_five_hour_soft_pct"); + + b.Property("UsageThrottleSevenDayHardPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(65) + .HasColumnName("usage_throttle_seven_day_hard_pct"); + + b.Property("UsageThrottleSevenDaySoftPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(50) + .HasColumnName("usage_throttle_seven_day_soft_pct"); + + b.Property("WorktreeAutoCleanupDays") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(7) + .HasColumnName("worktree_auto_cleanup_days"); + + b.Property("WorktreeAutoCleanupEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("worktree_auto_cleanup_enabled"); + + b.Property("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("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("note_date"); + + b.Property("FromAgent") + .HasColumnType("INTEGER") + .HasColumnName("from_agent"); + + b.Property("SortOrder") + .HasColumnType("INTEGER") + .HasColumnName("sort_order"); + + b.Property("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("ListId") + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("BaseBranch") + .HasColumnType("TEXT") + .HasColumnName("base_branch"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("PermissionMode") + .HasColumnType("TEXT") + .HasColumnName("permission_mode"); + + b.Property("PowerUseAllowed") + .HasColumnType("INTEGER") + .HasColumnName("power_use_allowed"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("TicketProjectId") + .HasColumnType("INTEGER") + .HasColumnName("ticket_project_id"); + + b.Property("VerifyCommand") + .HasColumnType("TEXT") + .HasColumnName("verify_command"); + + b.HasKey("ListId"); + + b.ToTable("list_config", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DefaultCommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("default_commit_type"); + + b.Property("FindingsTracked") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("findings_tracked"); + + b.Property("FolderId") + .HasColumnType("TEXT") + .HasColumnName("folder_id"); + + b.Property("Icon") + .HasColumnType("TEXT") + .HasColumnName("icon"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("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("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Color") + .HasColumnType("TEXT") + .HasColumnName("color"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Icon") + .HasColumnType("TEXT") + .HasColumnName("icon"); + + b.Property("IsCollapsed") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_collapsed"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("ParentFolderId") + .HasColumnType("TEXT") + .HasColumnName("parent_folder_id"); + + b.Property("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("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property("BriefPath") + .HasColumnType("TEXT") + .HasColumnName("brief_path"); + + b.Property("Cwd") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cwd"); + + b.Property("PredecessorSessionId") + .HasColumnType("TEXT") + .HasColumnName("predecessor_session_id"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("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("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Days") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(31) + .HasColumnName("days_of_week"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true) + .HasColumnName("enabled"); + + b.Property("Kind") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("ping") + .HasColumnName("action_kind"); + + b.Property("LastRunAt") + .HasColumnType("TEXT") + .HasColumnName("last_run_at"); + + b.Property("PromptOverride") + .HasColumnType("TEXT") + .HasColumnName("prompt_override"); + + b.Property("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("Name") + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("AddedAt") + .HasColumnType("TEXT") + .HasColumnName("added_at"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("PinnedRef") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("pinned_ref"); + + b.Property("SourceUrl") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("source_url"); + + b.Property("Subpath") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("subpath"); + + b.HasKey("Name"); + + b.ToTable("session_skills", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Completed") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("completed"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("OrderNum") + .HasColumnType("INTEGER") + .HasColumnName("order_num"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("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("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ByteSize") + .HasColumnType("INTEGER") + .HasColumnName("byte_size"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("file_name"); + + b.Property("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("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("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("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("BlockedByTaskId") + .HasColumnType("TEXT") + .HasColumnName("blocked_by_task_id"); + + b.Property("CommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("commit_type"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("TEXT") + .HasColumnName("created_by"); + + b.Property("Description") + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("ExpectsNoDiff") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("expects_no_diff"); + + b.Property("FailureMaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("failure_max_turns"); + + b.Property("FailureReason") + .HasColumnType("TEXT") + .HasColumnName("failure_reason"); + + b.Property("FailureTurnsUsed") + .HasColumnType("INTEGER") + .HasColumnName("failure_turns_used"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("HandlerBaseCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_base_commit"); + + b.Property("HandlerHeadCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_head_commit"); + + b.Property("InteractiveSessionId") + .HasColumnType("TEXT") + .HasColumnName("interactive_session_id"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property("IsMyDay") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_my_day"); + + b.Property("IsStarred") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_starred"); + + b.Property("ListId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Notes") + .HasColumnType("TEXT") + .HasColumnName("notes"); + + b.Property("Number") + .HasColumnType("INTEGER") + .HasColumnName("number"); + + b.Property("ParentTaskId") + .HasColumnType("TEXT") + .HasColumnName("parent_task_id"); + + b.Property("PermissionMode") + .HasColumnType("TEXT") + .HasColumnName("permission_mode"); + + b.Property("PlanningFinalizedAt") + .HasColumnType("TEXT") + .HasColumnName("planning_finalized_at"); + + b.Property("PlanningPhase") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("none") + .HasColumnName("planning_phase"); + + b.Property("PlanningSessionId") + .HasColumnType("TEXT") + .HasColumnName("planning_session_id"); + + b.Property("PlanningSessionToken") + .HasColumnType("TEXT") + .HasColumnName("planning_session_token"); + + b.Property("Result") + .HasColumnType("TEXT") + .HasColumnName("result"); + + b.Property("ReviewFeedback") + .HasColumnType("TEXT") + .HasColumnName("review_feedback"); + + b.Property("RoadblockCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("roadblock_count"); + + b.Property("ScheduledFor") + .HasColumnType("TEXT") + .HasColumnName("scheduled_for"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("TicketRef") + .HasColumnType("TEXT") + .HasColumnName("ticket_ref"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.Property("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("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CacheReadTokens") + .HasColumnType("INTEGER") + .HasColumnName("cache_read_tokens"); + + b.Property("CacheWriteTokens") + .HasColumnType("INTEGER") + .HasColumnName("cache_write_tokens"); + + b.Property("ErrorMarkdown") + .HasColumnType("TEXT") + .HasColumnName("error_markdown"); + + b.Property("Errors") + .HasColumnType("TEXT") + .HasColumnName("errors"); + + b.Property("ExitCode") + .HasColumnType("INTEGER") + .HasColumnName("exit_code"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("IsRetry") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_retry"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Prompt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("prompt"); + + b.Property("ResultMarkdown") + .HasColumnType("TEXT") + .HasColumnName("result_markdown"); + + b.Property("ResultSubtype") + .HasColumnType("TEXT") + .HasColumnName("result_subtype"); + + b.Property("RunNumber") + .HasColumnType("INTEGER") + .HasColumnName("run_number"); + + b.Property("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("StructuredOutputJson") + .HasColumnType("TEXT") + .HasColumnName("structured_output"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("TerminalReason") + .HasColumnType("TEXT") + .HasColumnName("terminal_reason"); + + b.Property("TokensIn") + .HasColumnType("INTEGER") + .HasColumnName("tokens_in"); + + b.Property("TokensOut") + .HasColumnType("INTEGER") + .HasColumnName("tokens_out"); + + b.Property("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("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("EndDate") + .HasColumnType("TEXT") + .HasColumnName("end_date"); + + b.Property("GeneratedAt") + .HasColumnType("TEXT") + .HasColumnName("generated_at"); + + b.Property("Markdown") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("markdown"); + + b.Property("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("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("BaseCommit") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("base_commit"); + + b.Property("BranchName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("branch_name"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DiffStat") + .HasColumnType("TEXT") + .HasColumnName("diff_stat"); + + b.Property("HeadCommit") + .HasColumnType("TEXT") + .HasColumnName("head_commit"); + + b.Property("MergeCommit") + .HasColumnType("TEXT") + .HasColumnName("merge_commit"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("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/20261001083902_AddListBaseBranch.cs b/src/ClaudeDo.Data/Migrations/20261001083902_AddListBaseBranch.cs new file mode 100644 index 00000000..5c0b883f --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20261001083902_AddListBaseBranch.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class AddListBaseBranch : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "base_branch", + table: "list_config", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "base_branch", + table: "list_config"); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index d8b462a4..79167da7 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -268,6 +268,10 @@ namespace ClaudeDo.Data.Migrations .HasColumnType("TEXT") .HasColumnName("agent_path"); + b.Property("BaseBranch") + .HasColumnType("TEXT") + .HasColumnName("base_branch"); + b.Property("MaxTurns") .HasColumnType("INTEGER") .HasColumnName("max_turns"); diff --git a/src/ClaudeDo.Data/Models/ListConfigEntity.cs b/src/ClaudeDo.Data/Models/ListConfigEntity.cs index 14545892..fba2ce67 100644 --- a/src/ClaudeDo.Data/Models/ListConfigEntity.cs +++ b/src/ClaudeDo.Data/Models/ListConfigEntity.cs @@ -23,6 +23,12 @@ public sealed class ListConfigEntity // false, also opt-in. ⚠️ Gleiche Falle wie TicketProjectId: SetConfigAsync kopiert verbatim. public bool PowerUseAllowed { get; set; } + /// + /// Basis neuer Worktrees und Default-Merge-/PR-Ziel (z.B. dev). Null = ausgecheckter Branch + /// (privat) bzw. Default-Branch des Remotes (Firma). ⚠️ SetConfigAsync kopiert verbatim. + /// + public string? BaseBranch { get; set; } + // Navigation property public ListEntity List { get; set; } = null!; } diff --git a/src/ClaudeDo.Data/Repositories/ListRepository.cs b/src/ClaudeDo.Data/Repositories/ListRepository.cs index 0a0a328d..ce66adec 100644 --- a/src/ClaudeDo.Data/Repositories/ListRepository.cs +++ b/src/ClaudeDo.Data/Repositories/ListRepository.cs @@ -175,6 +175,7 @@ public sealed class ListRepository existing.PermissionMode = config.PermissionMode; existing.TicketProjectId = config.TicketProjectId; existing.PowerUseAllowed = config.PowerUseAllowed; + existing.BaseBranch = config.BaseBranch; } await _context.SaveChangesAsync(ct); } diff --git a/src/ClaudeDo.Data/Wire.cs b/src/ClaudeDo.Data/Wire.cs index d62150db..8dfff1a7 100644 --- a/src/ClaudeDo.Data/Wire.cs +++ b/src/ClaudeDo.Data/Wire.cs @@ -108,11 +108,12 @@ public record UpdateListDto(string Id, string Name, string? WorkingDir, string D // verbatim. Its scale: null = keep stored, <= 0 = clear the link, > 0 = set it. // PowerUseAllowed is nullable for the same reason: null = keep stored, false = revoke the opt-in. // A non-nullable bool here would let any caller that omits it silently revoke PowerUse for the list. -public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null, string? VerifyCommand = null, string? PermissionMode = null, int? TicketProjectId = null, bool? PowerUseAllowed = null); +// BaseBranch: null = keep stored, "" = clear, value = set. +public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null, string? VerifyCommand = null, string? PermissionMode = null, int? TicketProjectId = null, bool? PowerUseAllowed = null, string? BaseBranch = null); public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null, string? PermissionMode = null); -public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null, string? VerifyCommand = null, string? PermissionMode = null, int? TicketProjectId = null, bool PowerUseAllowed = false); +public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null, string? VerifyCommand = null, string? PermissionMode = null, int? TicketProjectId = null, bool PowerUseAllowed = false, string? BaseBranch = null); public record SeedResultDto(int Copied, int Skipped); diff --git a/src/ClaudeDo.Worker/External/ConfigMcpTools.cs b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs index 78108c13..3c17c5bb 100644 --- a/src/ClaudeDo.Worker/External/ConfigMcpTools.cs +++ b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs @@ -10,7 +10,7 @@ using ModelContextProtocol.Server; namespace ClaudeDo.Worker.External; public sealed record TaskConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns); -public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns, string? VerifyCommand); +public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns, string? VerifyCommand, string? BaseBranch = null); public sealed record TaskConfigResult(bool Found, TaskConfigDto? Config); public sealed record ListConfigResult(bool Found, ListConfigDto? Config); public sealed record SetListConfigResult(bool Ok, string ListId, ListConfigDto? Config); @@ -55,7 +55,7 @@ public sealed class ConfigMcpTools var cfg = await _lists.GetConfigAsync(listId, cancellationToken); return cfg is null ? new ListConfigResult(false, null) - : new ListConfigResult(true, new ListConfigDto(cfg.Model, cfg.SystemPrompt, cfg.AgentPath, cfg.MaxTurns, cfg.VerifyCommand)); + : new ListConfigResult(true, new ListConfigDto(cfg.Model, cfg.SystemPrompt, cfg.AgentPath, cfg.MaxTurns, cfg.VerifyCommand, cfg.BaseBranch)); } // clearFields (not a per-field sentinel like UpdateList's "" for workingDir) because maxTurns is an @@ -81,7 +81,7 @@ public sealed class ConfigMcpTools "in this list that don't override them (verify command is list-only; tasks cannot override it). Only " + "the fields you pass are changed; omitted fields keep their current value. To clear a field instead, " + "name it in clearFields; clearing all five deletes the list's config unless it also carries settings " + - "this tool doesn't expose (session skills, file-scope serialization, permission mode), which are " + + "this tool doesn't expose (session skills, permission mode, ticket link, base branch, PowerUse opt-in), which are " + "always preserved.")] public async Task SetListConfig( string listId, string? model = null, string? systemPrompt = null, string? agentPath = null, @@ -110,7 +110,8 @@ public sealed class ConfigMcpTools // UI-only). var hasUnrelatedSettings = existing is not null && (existing.SessionSkills is not null - || existing.PermissionMode is not null || existing.TicketProjectId is not null); + || existing.PermissionMode is not null || existing.TicketProjectId is not null + || existing.BaseBranch is not null || existing.PowerUseAllowed); ListConfigDto? config; var allCleared = m is null && sp is null && ap is null && mt is null && vc is null; @@ -131,8 +132,10 @@ public sealed class ConfigMcpTools VerifyCommand = vc, SessionSkills = existing?.SessionSkills, PermissionMode = existing?.PermissionMode, TicketProjectId = existing?.TicketProjectId, + BaseBranch = existing?.BaseBranch, + PowerUseAllowed = existing?.PowerUseAllowed ?? false, }, cancellationToken); - config = allCleared ? null : new ListConfigDto(m, sp, ap, mt, vc); + config = allCleared ? null : new ListConfigDto(m, sp, ap, mt, vc, existing?.BaseBranch); } await _broadcaster.ListUpdated(listId); diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index baf8568c..c3001612 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -600,9 +600,12 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub // Gleiches Tri-State-Muster: null = gespeicherten Wert behalten, false = Opt-in widerrufen. var powerUseAllowed = dto.PowerUseAllowed ?? existing?.PowerUseAllowed ?? false; + // Tri-State wie oben: null = behalten, "" = löschen. + var baseBranch = dto.BaseBranch is null ? existing?.BaseBranch : dto.BaseBranch.NullIfBlank(); + // powerUseAllowed muss mit in die Leer-Prüfung: sonst löscht ein Save, bei dem PowerUse das // EINZIGE gesetzte Feld ist, die Config-Zeile und damit das Opt-in gleich wieder mit. - if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null && verifyCommand is null && permissionMode is null && ticketProjectId is null && !powerUseAllowed) + if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null && verifyCommand is null && permissionMode is null && ticketProjectId is null && !powerUseAllowed && baseBranch is null) { await repo.DeleteConfigAsync(dto.ListId); } @@ -620,6 +623,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub PermissionMode = permissionMode, TicketProjectId = ticketProjectId, PowerUseAllowed = powerUseAllowed, + BaseBranch = baseBranch, }); } @@ -643,7 +647,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub var repo = new ListRepository(ctx); var config = await repo.GetConfigAsync(listId); if (config is null) return null; - return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills), config.VerifyCommand, config.PermissionMode, config.TicketProjectId, config.PowerUseAllowed); + return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills), config.VerifyCommand, config.PermissionMode, config.TicketProjectId, config.PowerUseAllowed, config.BaseBranch); } public async Task SetTaskStatus(string taskId, string status) diff --git a/tests/ClaudeDo.Worker.Tests/Hub/ListConfigBaseBranchTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/ListConfigBaseBranchTests.cs new file mode 100644 index 00000000..ae940e62 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Hub/ListConfigBaseBranchTests.cs @@ -0,0 +1,100 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.External; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Tests.Infrastructure; +using Xunit; + +namespace ClaudeDo.Worker.Tests.Hub; + +/// BaseBranch und PowerUseAllowed sitzen in der SetConfigAsync-kopiert-verbatim-Falle: jeder Writer, +/// der sie nicht durchreicht, setzt sie still zurück. +public sealed class ListConfigBaseBranchTests : IDisposable +{ + private readonly DbFixture _db = new(); + public void Dispose() => _db.Dispose(); + + private WorkerHub CreateHub() + { + var factory = _db.CreateFactory(); + var broadcaster = new HubBroadcaster(new CapturingHubContext()); + var hub = new WorkerHub( + null!, null!, null!, null!, broadcaster, factory, + null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, + null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")), + new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!); + hub.Clients = new FakeHubCallerClients(new RecordingClientProxy()); + hub.Context = new FakeHubCallerContext(); + return hub; + } + + private async Task SeedListAsync(string? baseBranch, bool powerUse = false) + { + var listId = Guid.NewGuid().ToString(); + await using var ctx = _db.CreateContext(); + var repo = new ListRepository(ctx); + await repo.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); + await repo.SetConfigAsync(new ListConfigEntity { ListId = listId, BaseBranch = baseBranch, PowerUseAllowed = powerUse }); + return listId; + } + + private async Task ReadConfigAsync(string listId) + { + await using var ctx = _db.CreateContext(); + return await new ListRepository(ctx).GetConfigAsync(listId); + } + + private ConfigMcpTools CreateTool(ClaudeDoDbContext ctx) => + new(new ListRepository(ctx), new TaskRepository(ctx), + new HubBroadcaster(new CapturingHubContext()), _db.CreateFactory()); + + [Fact] + public async Task set_list_config_preserves_BaseBranch_and_PowerUseAllowed() + { + var listId = await SeedListAsync("dev", powerUse: true); + await using var ctx = _db.CreateContext(); + + await CreateTool(ctx).SetListConfig(listId, model: "opus", cancellationToken: CancellationToken.None); + + var cfg = await ReadConfigAsync(listId); + Assert.Equal("dev", cfg!.BaseBranch); + Assert.True(cfg.PowerUseAllowed); + } + + [Fact] + public async Task get_list_config_returns_BaseBranch() + { + var listId = await SeedListAsync("dev"); + await using var ctx = _db.CreateContext(); + + var result = await CreateTool(ctx).GetListConfig(listId, CancellationToken.None); + + Assert.Equal("dev", result.Config!.BaseBranch); + } + + [Fact] + public async Task UpdateListConfig_null_keeps_BaseBranch() + { + var listId = await SeedListAsync("dev"); + await CreateHub().UpdateListConfig(new UpdateListConfigDto(listId, "opus", null, null)); + Assert.Equal("dev", (await ReadConfigAsync(listId))!.BaseBranch); + } + + [Fact] + public async Task UpdateListConfig_empty_clears_BaseBranch() + { + var listId = await SeedListAsync("dev"); + await CreateHub().UpdateListConfig(new UpdateListConfigDto(listId, "opus", null, null, BaseBranch: "")); + Assert.Null((await ReadConfigAsync(listId))!.BaseBranch); + } + + [Fact] + public async Task UpdateListConfig_sets_BaseBranch_and_GetListConfig_returns_it() + { + var listId = await SeedListAsync(null); + var hub = CreateHub(); + await hub.UpdateListConfig(new UpdateListConfigDto(listId, null, null, null, BaseBranch: "dev")); + Assert.Equal("dev", (await hub.GetListConfig(listId))!.BaseBranch); + } +}