From fe0bda1e074c436bc2045ceeed6c284b26d9bea6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Wed, 26 Aug 2026 11:09:00 +0000 Subject: [PATCH] feat(workspaces): add repository branch policies (#2997) --- .../backend/internal/backendapp/boot_state.go | 5 + .../internal/backendapp/boot_state_routes.go | 104 +++- .../backendapp/branch_materializer.go | 12 +- .../backendapp/branch_materializer_test.go | 12 + apps/backend/internal/backendapp/helpers.go | 1 + apps/backend/internal/backendapp/services.go | 1 + apps/backend/internal/events/types.go | 7 + .../gateway/websocket/task_notifications.go | 3 + .../websocket/task_notifications_test.go | 2 +- .../orchestrator/event_handlers_workflow.go | 15 +- .../orchestrator/executor/executor_resume.go | 6 + .../executor/executor_resume_test.go | 41 ++ apps/backend/internal/orchestrator/service.go | 14 +- .../internal/orchestrator/task_operations.go | 15 +- .../task_pull_request_target_prompt.go | 62 ++ .../task_pull_request_target_prompt_test.go | 137 +++++ .../orchestrator/task_title_branch.go | 14 +- .../orchestrator/task_title_branch_runtime.go | 2 +- .../orchestrator/task_title_branch_test.go | 23 +- apps/backend/internal/sysprompt/sysprompt.go | 64 ++ apps/backend/internal/task/dto/dto.go | 72 ++- apps/backend/internal/task/dto/requests.go | 5 + apps/backend/internal/task/handlers/errors.go | 17 +- .../internal/task/handlers/errors_test.go | 20 + .../task/handlers/message_handlers.go | 11 +- ...sage_handlers_branch_policy_prompt_test.go | 85 +++ .../message_pull_request_target_prompt.go | 25 + .../repository_branch_policy_handlers.go | 303 ++++++++++ .../repository_branch_policy_handlers_test.go | 249 ++++++++ .../internal/task/handlers/task_handlers.go | 28 +- .../task/handlers/task_http_handlers.go | 28 +- .../task/handlers/task_http_handlers_test.go | 16 + .../task/handlers/task_ws_handlers.go | 4 +- apps/backend/internal/task/models/models.go | 38 +- .../promptcontext/pull_request_targets.go | 43 ++ .../pull_request_targets_test.go | 33 ++ .../internal/task/repository/interface.go | 13 + .../task/repository/repoerrors/errors.go | 7 + .../task/repository/sqlite/base_migrations.go | 25 + .../task/repository/sqlite/base_schema.go | 30 + .../sqlite/relationship_crud_coverage_test.go | 17 +- .../sqlite/repository_branch_policy.go | 172 ++++++ .../sqlite/repository_branch_policy_test.go | 148 +++++ .../repository/sqlite/repository_entity.go | 1 + .../task/repository/sqlite/task_repository.go | 47 +- ...repository_branch_policy_migration_test.go | 66 +++ apps/backend/internal/task/service/service.go | 3 + .../internal/task/service/service_events.go | 39 +- .../service_events_branch_policy_test.go | 69 +++ .../service_repository_branch_policies.go | 349 +++++++++++ ...service_repository_branch_policies_test.go | 286 +++++++++ .../internal/task/service/service_requests.go | 11 +- .../service_task_branch_policy_snapshot.go | 115 ++++ .../internal/task/service/service_tasks.go | 32 + .../internal/task/service/service_test.go | 1 + .../internal/task/service/service_turns.go | 6 +- apps/backend/pkg/api/v1/task.go | 5 +- apps/backend/pkg/websocket/actions.go | 167 +++--- .../tasks/[id]/office-dockview-layout.tsx | 25 +- .../office/tasks/[id]/task-advanced-mode.tsx | 2 +- .../workspace-repositories-client.tsx | 1 + apps/web/components/kanban-board.tsx | 2 + apps/web/components/kanban-card.tsx | 13 +- .../settings/repository-branch-policies.tsx | 556 ++++++++++++++++++ .../repository-branch-policy-fields.test.tsx | 52 ++ .../repository-branch-policy-fields.tsx | 269 +++++++++ .../settings/repository-card.test.tsx | 38 +- .../components/settings/repository-card.tsx | 8 + .../task-create-dialog-branch-options.tsx | 2 + .../task-create-dialog-form-body.test.tsx | 2 + .../task-create-dialog-form-reset.ts | 72 +++ .../components/task-create-dialog-handlers.ts | 16 +- ...k-create-dialog-helpers.multi-repo.test.ts | 35 +- .../components/task-create-dialog-helpers.ts | 1 + .../task-create-dialog-locked-fields.ts | 1 + .../task-create-dialog-pill.test.tsx | 38 ++ .../components/task-create-dialog-pill.tsx | 92 ++- .../task-create-dialog-prop-builders.ts | 1 + .../task-create-dialog-repo-chip-parts.tsx | 295 ++++++++++ .../task-create-dialog-repo-chips.test.tsx | 58 +- .../task-create-dialog-repo-chips.tsx | 27 + .../task-create-dialog-repositories-state.ts | 14 +- ...ask-create-dialog-repository-sets-apply.ts | 5 +- .../task-create-dialog-selectors.tsx | 16 +- .../task-create-dialog-setup.test.ts | 7 + .../components/task-create-dialog-setup.ts | 53 +- .../task-create-dialog-state.test.ts | 42 ++ .../components/task-create-dialog-state.ts | 72 +-- .../task-create-dialog-submit.test.tsx | 118 +++- .../components/task-create-dialog-submit.tsx | 57 +- .../components/task-create-dialog-types.ts | 32 + ...reate-dialog-workspace-repo-chips.test.tsx | 84 ++- ...ask-create-dialog-workspace-repo-chips.tsx | 278 +++++---- .../components/task-create-dialog.test.tsx | 7 + apps/web/components/task-create-dialog.tsx | 5 + .../task/mobile/session-mobile-layout.tsx | 3 + .../session-mobile-top-bar-git-controls.tsx | 12 +- .../task/mobile/session-mobile-top-bar.tsx | 50 +- .../session-task-switcher-sheet-hooks.ts | 5 + .../components/task/new-subtask-dialog.tsx | 20 +- .../task/new-subtask-form-parts.test.tsx | 2 + .../task/new-subtask-form-parts.tsx | 19 + .../task/new-subtask-form-state.test.ts | 10 + .../components/task/new-subtask-form-state.ts | 24 +- apps/web/components/task/task-layout.tsx | 3 + .../task/task-page-content-helpers.test.ts | 74 +++ .../task/task-page-content-helpers.ts | 69 ++- apps/web/components/task/task-page-inner.tsx | 9 +- .../task/task-session-sidebar-edit.tsx | 5 + .../task/use-subtask-submit.test.ts | 37 ++ .../web/components/task/use-subtask-submit.ts | 16 + apps/web/components/vcs/vcs-dialogs.test.ts | 26 +- apps/web/components/vcs/vcs-dialogs.tsx | 40 +- apps/web/e2e/helpers/api-client.ts | 45 ++ .../mobile-repository-branch-policies.spec.ts | 48 ++ .../repository-branch-policies.spec.ts | 126 ++++ .../task/create-task-branch-policy-helpers.ts | 14 + .../task/create-task-branch-policy.spec.ts | 184 ++++++ .../mobile-create-task-branch-policy.spec.ts | 140 +++++ .../task/mobile-sidebar-task-actions.spec.ts | 123 ++++ apps/web/e2e/tests/task/subtask.spec.ts | 126 ++++ .../use-repository-branch-policies.test.tsx | 169 ++++++ .../use-repository-branch-policies.ts | 206 +++++++ apps/web/lib/api/client.test.ts | 25 + apps/web/lib/api/client.ts | 8 + apps/web/lib/api/domains/kanban-api.ts | 1 + apps/web/lib/api/domains/workspace-api.ts | 94 +++ apps/web/lib/kanban/map-task.test.ts | 28 + apps/web/lib/kanban/map-task.ts | 10 + apps/web/lib/ssr/mapper.ts | 5 + apps/web/lib/state/default-state.ts | 5 + apps/web/lib/state/hydration/hydrator.ts | 2 + apps/web/lib/state/slices/kanban/types.ts | 5 + .../repository-branch-policies-slice.test.ts | 85 +++ apps/web/lib/state/slices/workspace/types.ts | 24 +- .../state/slices/workspace/workspace-slice.ts | 65 +- apps/web/lib/state/store.ts | 10 + apps/web/lib/types/backend.ts | 33 ++ apps/web/lib/types/http-workspace-sources.ts | 17 + apps/web/lib/types/http.ts | 12 +- .../repository-branch-policies.test.ts | 73 +++ .../ws/handlers/repository-branch-policies.ts | 39 ++ apps/web/lib/ws/router.ts | 2 + apps/web/src/boot-payload.ts | 11 +- apps/web/src/locales/en/task.json | 9 +- apps/web/src/locales/en/workspaces.json | 51 +- apps/web/src/locales/pseudo/task.json | 9 +- apps/web/src/locales/pseudo/workspaces.json | 51 +- apps/web/src/locales/pt-pt/task.json | 9 +- apps/web/src/locales/pt-pt/workspaces.json | 51 +- apps/web/src/locales/zh-cn/task.json | 9 +- apps/web/src/locales/zh-cn/workspaces.json | 51 +- apps/web/src/locales/zh-hk/task.json | 9 +- apps/web/src/locales/zh-hk/workspaces.json | 51 +- apps/web/src/locales/zh-tw/task.json | 9 +- apps/web/src/locales/zh-tw/workspaces.json | 51 +- ...0032-configurable-worktree-branch-names.md | 6 +- ...-08-24-task-snapshotted-branch-policies.md | 69 +++ docs/decisions/INDEX.md | 3 +- docs/plans/branch-policies/plan.md | 125 ++++ .../task-01-branch-policy-persistence-api.md | 108 ++++ .../task-02-task-policy-snapshot-runtime.md | 125 ++++ .../task-03-repository-settings-policies.md | 120 ++++ .../task-04-task-create-policy-selector.md | 132 +++++ .../task-05-branch-policy-e2e.md | 122 ++++ .../task-06-branch-policy-public-docs.md | 97 +++ docs/public/git-operations.md | 30 + docs/public/tasks-and-workflows.md | 16 +- docs/specs/INDEX.md | 2 +- docs/specs/workspaces/README.md | 4 +- .../requirements/branch-policies.md | 214 +++++++ .../requirements/worktree-branch-templates.md | 6 + .../system-design/branch-policies.md | 256 ++++++++ 173 files changed, 8951 insertions(+), 525 deletions(-) create mode 100644 apps/backend/internal/orchestrator/task_pull_request_target_prompt.go create mode 100644 apps/backend/internal/orchestrator/task_pull_request_target_prompt_test.go create mode 100644 apps/backend/internal/task/handlers/message_handlers_branch_policy_prompt_test.go create mode 100644 apps/backend/internal/task/handlers/message_pull_request_target_prompt.go create mode 100644 apps/backend/internal/task/handlers/repository_branch_policy_handlers.go create mode 100644 apps/backend/internal/task/handlers/repository_branch_policy_handlers_test.go create mode 100644 apps/backend/internal/task/promptcontext/pull_request_targets.go create mode 100644 apps/backend/internal/task/promptcontext/pull_request_targets_test.go create mode 100644 apps/backend/internal/task/repository/sqlite/repository_branch_policy.go create mode 100644 apps/backend/internal/task/repository/sqlite/repository_branch_policy_test.go create mode 100644 apps/backend/internal/task/repository/sqlite/task_repository_branch_policy_migration_test.go create mode 100644 apps/backend/internal/task/service/service_events_branch_policy_test.go create mode 100644 apps/backend/internal/task/service/service_repository_branch_policies.go create mode 100644 apps/backend/internal/task/service/service_repository_branch_policies_test.go create mode 100644 apps/backend/internal/task/service/service_task_branch_policy_snapshot.go create mode 100644 apps/web/components/settings/repository-branch-policies.tsx create mode 100644 apps/web/components/settings/repository-branch-policy-fields.test.tsx create mode 100644 apps/web/components/settings/repository-branch-policy-fields.tsx create mode 100644 apps/web/components/task-create-dialog-form-reset.ts create mode 100644 apps/web/components/task-create-dialog-repo-chip-parts.tsx create mode 100644 apps/web/e2e/tests/settings/mobile-repository-branch-policies.spec.ts create mode 100644 apps/web/e2e/tests/settings/repository-branch-policies.spec.ts create mode 100644 apps/web/e2e/tests/task/create-task-branch-policy-helpers.ts create mode 100644 apps/web/e2e/tests/task/create-task-branch-policy.spec.ts create mode 100644 apps/web/e2e/tests/task/mobile-create-task-branch-policy.spec.ts create mode 100644 apps/web/hooks/domains/workspace/use-repository-branch-policies.test.tsx create mode 100644 apps/web/hooks/domains/workspace/use-repository-branch-policies.ts create mode 100644 apps/web/lib/state/slices/workspace/repository-branch-policies-slice.test.ts create mode 100644 apps/web/lib/ws/handlers/repository-branch-policies.test.ts create mode 100644 apps/web/lib/ws/handlers/repository-branch-policies.ts create mode 100644 docs/decisions/2026-08-24-task-snapshotted-branch-policies.md create mode 100644 docs/plans/branch-policies/plan.md create mode 100644 docs/plans/branch-policies/task-01-branch-policy-persistence-api.md create mode 100644 docs/plans/branch-policies/task-02-task-policy-snapshot-runtime.md create mode 100644 docs/plans/branch-policies/task-03-repository-settings-policies.md create mode 100644 docs/plans/branch-policies/task-04-task-create-policy-selector.md create mode 100644 docs/plans/branch-policies/task-05-branch-policy-e2e.md create mode 100644 docs/plans/branch-policies/task-06-branch-policy-public-docs.md create mode 100644 docs/specs/workspaces/requirements/branch-policies.md create mode 100644 docs/specs/workspaces/system-design/branch-policies.md diff --git a/apps/backend/internal/backendapp/boot_state.go b/apps/backend/internal/backendapp/boot_state.go index 9d08ab35f1..3911df4563 100644 --- a/apps/backend/internal/backendapp/boot_state.go +++ b/apps/backend/internal/backendapp/boot_state.go @@ -318,6 +318,7 @@ func (b bootStateBuilder) addHomeKanbanRouteState(ctx context.Context, req *http } b.addRepositoriesState(ctx, state, activeWorkspaceID) b.addRepositorySetsState(ctx, state, activeWorkspaceID) + b.addRepositoryBranchPoliciesState(ctx, state, activeWorkspaceID) b.addKanbanSnapshotsState(ctx, state, workflows, activeWorkflowID) } @@ -393,6 +394,10 @@ func (b bootStateBuilder) addRepositorySetsState(ctx context.Context, state map[ state["repositorySets"] = repositorySetsState(workspaceID, items, loaded) } +func (b bootStateBuilder) addRepositoryBranchPoliciesState(ctx context.Context, state map[string]any, workspaceID string) { + b.repositoryBranchPoliciesForState(ctx, workspaceID, state) +} + func (b bootStateBuilder) addQuickChatState( ctx context.Context, req *http.Request, diff --git a/apps/backend/internal/backendapp/boot_state_routes.go b/apps/backend/internal/backendapp/boot_state_routes.go index 7e6fd30e38..2fe22ee440 100644 --- a/apps/backend/internal/backendapp/boot_state_routes.go +++ b/apps/backend/internal/backendapp/boot_state_routes.go @@ -62,7 +62,7 @@ func (b bootStateBuilder) tasksPageBootData(ctx context.Context, req *http.Reque state["userSettings"] = mapUserSettingsState(settings, activeWorkspaceID) } if activeWorkspaceID == "" { - return state, map[string]any{"activeWorkspaceId": nil, "workflows": []any{}, "steps": []any{}, "repositories": []any{}, "repositorySets": []any{}, "tasks": []any{}, "total": 0, "tasksListSort": tasksListSort, "tasksListGroup": tasksListGroup} + return state, map[string]any{"activeWorkspaceId": nil, "workflows": []any{}, "steps": []any{}, "repositories": []any{}, "repositorySets": []any{}, "repositoryBranchPolicies": []any{}, "tasks": []any{}, "total": 0, "tasksListSort": tasksListSort, "tasksListGroup": tasksListGroup} } workflows, err := b.p.taskSvc.ListWorkflows(ctx, activeWorkspaceID, false) if err != nil { @@ -77,18 +77,20 @@ func (b bootStateBuilder) tasksPageBootData(ctx context.Context, req *http.Reque } repositories := b.repositoriesForState(ctx, activeWorkspaceID, state) repositorySets := b.repositorySetsForState(ctx, activeWorkspaceID, state) + repositoryBranchPolicies := b.repositoryBranchPoliciesForState(ctx, activeWorkspaceID, state) steps := b.workflowStepsForWorkspace(ctx, activeWorkspaceID) tasks, total := b.tasksForWorkspace(ctx, activeWorkspaceID, activeWorkflowID, settingsRepositoryID, tasksListSort) routeData := map[string]any{ - "activeWorkspaceId": activeWorkspaceID, - "workflows": workflowsToDTOs(workflows), - "steps": steps, - "repositories": repositories, - "repositorySets": repositorySets, - "tasks": tasks, - "total": total, - "tasksListSort": tasksListSort, - "tasksListGroup": tasksListGroup, + "activeWorkspaceId": activeWorkspaceID, + "workflows": workflowsToDTOs(workflows), + "steps": steps, + "repositories": repositories, + "repositorySets": repositorySets, + "repositoryBranchPolicies": repositoryBranchPolicies, + "tasks": tasks, + "total": total, + "tasksListSort": tasksListSort, + "tasksListGroup": tasksListGroup, } return state, routeData } @@ -128,7 +130,7 @@ func (b bootStateBuilder) routeContextBootData(ctx context.Context, req *http.Re state["userSettings"] = mapUserSettingsState(settings, activeWorkspaceID) } if activeWorkspaceID == "" { - return state, map[string]any{"activeWorkspaceId": nil, "workflows": []any{}, "steps": []any{}, "repositories": []any{}, "repositorySets": []any{}} + return state, map[string]any{"activeWorkspaceId": nil, "workflows": []any{}, "steps": []any{}, "repositories": []any{}, "repositorySets": []any{}, "repositoryBranchPolicies": []any{}} } workflows, err := b.p.taskSvc.ListWorkflows(ctx, activeWorkspaceID, false) if err != nil { @@ -145,13 +147,15 @@ func (b bootStateBuilder) routeContextBootData(ctx context.Context, req *http.Re } repositories := b.repositoriesForState(ctx, activeWorkspaceID, state) repositorySets := b.repositorySetsForState(ctx, activeWorkspaceID, state) + repositoryBranchPolicies := b.repositoryBranchPoliciesForState(ctx, activeWorkspaceID, state) steps := b.workflowStepsForWorkspace(ctx, activeWorkspaceID) return state, map[string]any{ - "activeWorkspaceId": activeWorkspaceID, - "workflows": workflowsToDTOs(workflows), - "steps": steps, - "repositories": repositories, - "repositorySets": repositorySets, + "activeWorkspaceId": activeWorkspaceID, + "workflows": workflowsToDTOs(workflows), + "steps": steps, + "repositories": repositories, + "repositorySets": repositorySets, + "repositoryBranchPolicies": repositoryBranchPolicies, } } @@ -244,6 +248,64 @@ func (b bootStateBuilder) repositorySetsForState( return items } +// repositoryBranchPoliciesForState hydrates the repository-keyed policy slice +// used by repository settings and the task-create picker. +func (b bootStateBuilder) repositoryBranchPoliciesForState( + ctx context.Context, + workspaceID string, + state map[string]any, +) []taskdto.RepositoryBranchPolicyDTO { + itemsByRepositoryID := map[string]any{} + loadingByRepositoryID := map[string]any{} + loadedByRepositoryID := map[string]any{} + items := make([]taskdto.RepositoryBranchPolicyDTO, 0) + repositories, err := b.p.taskSvc.ListRepositories(ctx, workspaceID) + if err != nil { + b.logBootError("list repositories for branch policies", err) + state["repositoryBranchPolicies"] = map[string]any{ + "itemsByRepositoryId": itemsByRepositoryID, + "loadingByRepositoryId": loadingByRepositoryID, + "loadedByRepositoryId": loadedByRepositoryID, + } + return items + } + policies, err := b.p.taskSvc.ListRepositoryBranchPoliciesForWorkspace(ctx, workspaceID) + if err != nil { + b.logBootError("list repository branch policies", err) + for _, repository := range repositories { + if repository == nil { + continue + } + itemsByRepositoryID[repository.ID] = []taskdto.RepositoryBranchPolicyDTO{} + loadedByRepositoryID[repository.ID] = false + loadingByRepositoryID[repository.ID] = false + } + } else { + policiesByRepositoryID := make(map[string][]*taskmodels.RepositoryBranchPolicy) + for _, policy := range policies { + if policy != nil { + policiesByRepositoryID[policy.RepositoryID] = append(policiesByRepositoryID[policy.RepositoryID], policy) + } + } + for _, repository := range repositories { + if repository == nil { + continue + } + dtos := repositoryBranchPoliciesToDTOs(policiesByRepositoryID[repository.ID]) + itemsByRepositoryID[repository.ID] = dtos + loadedByRepositoryID[repository.ID] = true + loadingByRepositoryID[repository.ID] = false + items = append(items, dtos...) + } + } + state["repositoryBranchPolicies"] = map[string]any{ + "itemsByRepositoryId": itemsByRepositoryID, + "loadingByRepositoryId": loadingByRepositoryID, + "loadedByRepositoryId": loadedByRepositoryID, + } + return items +} + // repositorySetsState is the workspace-keyed slice shape the web store expects. // An empty workspace still keys an entry: an absent key reads as "not loaded" to // the client hook, which then refetches on every dialog open. @@ -270,6 +332,16 @@ func repositorySetsToDTOs(sets []*taskmodels.RepositorySet) []taskdto.Repository return items } +func repositoryBranchPoliciesToDTOs(policies []*taskmodels.RepositoryBranchPolicy) []taskdto.RepositoryBranchPolicyDTO { + items := make([]taskdto.RepositoryBranchPolicyDTO, 0, len(policies)) + for _, policy := range policies { + if policy != nil { + items = append(items, taskdto.FromRepositoryBranchPolicy(policy)) + } + } + return items +} + // repositoriesToDTOs maps repository models to DTOs. func repositoriesToDTOs(repositories []*taskmodels.Repository) []taskdto.RepositoryDTO { items := make([]taskdto.RepositoryDTO, 0, len(repositories)) diff --git a/apps/backend/internal/backendapp/branch_materializer.go b/apps/backend/internal/backendapp/branch_materializer.go index c5ee727f6f..67e0b6be54 100644 --- a/apps/backend/internal/backendapp/branch_materializer.go +++ b/apps/backend/internal/backendapp/branch_materializer.go @@ -169,7 +169,7 @@ func (b *branchMaterializer) prepareMaterializeRequest( FallbackBaseBranch: repo.DefaultBranch, CheckoutBranch: tr.CheckoutBranch, WorktreeBranchPrefix: repo.WorktreeBranchPrefix, - WorktreeBranchTemplate: repo.WorktreeBranchTemplate, + WorktreeBranchTemplate: taskRepositoryBranchTemplate(repo, tr), WorktreeBranchTicket: worktree.TicketForBranchName(task.Identifier, task.Metadata), PullBeforeWorktree: repo.PullBeforeWorktree, TaskDirName: env.TaskDirName, @@ -179,6 +179,16 @@ func (b *branchMaterializer) prepareMaterializeRequest( return req, env, session, slug, true, nil } +func taskRepositoryBranchTemplate(repo *models.Repository, taskRepo *models.TaskRepository) string { + if taskRepo != nil && taskRepo.BranchPolicyBranchTemplate != "" { + return taskRepo.BranchPolicyBranchTemplate + } + if repo == nil { + return "" + } + return repo.WorktreeBranchTemplate +} + // finalizeMaterialize handles the post-Create plumbing: promote the task // environment's workspace_path to the task root when the new worktree is a // sibling, ping the running agentctl to add per-repo trackers, and emit a diff --git a/apps/backend/internal/backendapp/branch_materializer_test.go b/apps/backend/internal/backendapp/branch_materializer_test.go index c45354f917..94c1a46933 100644 --- a/apps/backend/internal/backendapp/branch_materializer_test.go +++ b/apps/backend/internal/backendapp/branch_materializer_test.go @@ -202,6 +202,18 @@ func TestBranchMaterializer_SecondBranchKeepsTaskRootPromoted(t *testing.T) { } } +func TestTaskRepositoryBranchTemplatePrefersPolicySnapshot(t *testing.T) { + repo := &models.Repository{WorktreeBranchTemplate: "feature/{title}"} + legacy := &models.TaskRepository{} + if got := taskRepositoryBranchTemplate(repo, legacy); got != "feature/{title}" { + t.Fatalf("legacy template = %q, want repository fallback", got) + } + policy := &models.TaskRepository{BranchPolicyBranchTemplate: "bugfix/{title}-{suffix}"} + if got := taskRepositoryBranchTemplate(repo, policy); got != "bugfix/{title}-{suffix}" { + t.Fatalf("policy template = %q, want snapshot template", got) + } +} + // setupMaterializerScenario creates a bare origin repo + a clone that // serves as the "primary" worktree at /kandev/. Returns the // repository path, the task root, and the primary worktree path. diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index 0143179516..991d3a3843 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -1146,6 +1146,7 @@ func registerTaskRoutes(p routeParams, planService *taskservice.PlanService, han } taskhandlers.RegisterRepositoryRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.log) taskhandlers.RegisterRepositorySetRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.log) + taskhandlers.RegisterRepositoryBranchPolicyRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.log) taskhandlers.RegisterExecutorRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.log) taskhandlers.RegisterExecutorProfileRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.agentList, p.log) taskhandlers.RegisterEnvironmentRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.log) diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index acc8955b01..d16d298da6 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -135,6 +135,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories GitSnapshots: repos.Task, RepoEntities: repos.Task, RepositorySets: repos.Task, + BranchPolicies: repos.Task, RepositoryCleanup: repos.Task, Executors: repos.Task, Environments: repos.Task, diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index 6ea107ed67..deadb55dfe 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -140,6 +140,13 @@ const ( RepositorySetDeleted = "repository_set.deleted" ) +// Event types for repository branch policies. +const ( + RepositoryBranchPolicyCreated = "repository_branch_policy.created" + RepositoryBranchPolicyUpdated = "repository_branch_policy.updated" + RepositoryBranchPolicyDeleted = "repository_branch_policy.deleted" +) + // Event types for repository scripts const ( RepositoryScriptCreated = "repository.script.created" diff --git a/apps/backend/internal/gateway/websocket/task_notifications.go b/apps/backend/internal/gateway/websocket/task_notifications.go index 235958aeb7..e80be4f6dd 100644 --- a/apps/backend/internal/gateway/websocket/task_notifications.go +++ b/apps/backend/internal/gateway/websocket/task_notifications.go @@ -63,6 +63,9 @@ func RegisterTaskNotifications(ctx context.Context, eventBus bus.EventBus, hub * b.subscribe(eventBus, events.RepositorySetCreated, ws.ActionRepositorySetCreated) b.subscribe(eventBus, events.RepositorySetUpdated, ws.ActionRepositorySetUpdated) b.subscribe(eventBus, events.RepositorySetDeleted, ws.ActionRepositorySetDeleted) + b.subscribe(eventBus, events.RepositoryBranchPolicyCreated, ws.ActionRepositoryBranchPolicyCreated) + b.subscribe(eventBus, events.RepositoryBranchPolicyUpdated, ws.ActionRepositoryBranchPolicyUpdated) + b.subscribe(eventBus, events.RepositoryBranchPolicyDeleted, ws.ActionRepositoryBranchPolicyDeleted) b.subscribe(eventBus, events.RepositoryScriptCreated, ws.ActionRepositoryScriptCreated) b.subscribe(eventBus, events.RepositoryScriptUpdated, ws.ActionRepositoryScriptUpdated) b.subscribe(eventBus, events.RepositoryScriptDeleted, ws.ActionRepositoryScriptDeleted) diff --git a/apps/backend/internal/gateway/websocket/task_notifications_test.go b/apps/backend/internal/gateway/websocket/task_notifications_test.go index 8cfe46bc59..fd39c5f424 100644 --- a/apps/backend/internal/gateway/websocket/task_notifications_test.go +++ b/apps/backend/internal/gateway/websocket/task_notifications_test.go @@ -111,7 +111,7 @@ func TestTaskEventBroadcaster_NoDuplicateSubscriptions(t *testing.T) { // // Update this number when adding or removing event subscriptions in // RegisterTaskNotifications — it is intentionally exact. - const wantSubscriptions = 67 + const wantSubscriptions = 70 if got := len(b.subscriptions); got != wantSubscriptions { t.Errorf("RegisterTaskNotifications created %d subscriptions, want %d — "+ "did an event get subscribed twice?", got, wantSubscriptions) diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index 70c832617a..b82c73e679 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -3049,21 +3049,30 @@ func (s *Service) autoStartStepPrompt( return contextErr } } + var pullRequestTargetContext string + if needsRuntimeContext { + recordedPrompt, pullRequestTargetContext = s.addTaskPullRequestTargetContext( + ctx, taskID, recordedPrompt, session.IsPassthrough, + ) + } if (session.State == models.TaskSessionStateCreated || step.HasOnEnterAction(wfmodels.OnEnterResetAgentContext)) && !session.IsPassthrough && (agentPrompt != "" || len(attachments) > 0) { configMode, _ := session.Metadata["config_mode"].(bool) requiresSignal := step != nil && step.AutoAdvanceRequiresSignal referenceContext := EntityReferenceContext(references) if isOfficeTask { - recordedPrompt = sysprompt.InjectOfficeContextWithOptions(taskID, sessionID, agentPrompt, requiresSignal, referenceContext) + recordedPrompt = sysprompt.InjectOfficeContextWithOptions( + taskID, sessionID, recordedPrompt, requiresSignal, + referenceContext, pullRequestTargetContext, + ) } else { - recordedPrompt = sysprompt.InjectKandevContextWithOptions(taskID, sessionID, agentPrompt, sysprompt.KandevContextOptions{ + recordedPrompt = sysprompt.InjectKandevContextWithOptions(taskID, sessionID, recordedPrompt, sysprompt.KandevContextOptions{ RequiresCompletionSignal: requiresSignal, IncludeCoordinatorTaskControls: !configMode, IncludeTaskTitleTool: !configMode && titleOwner, Autopilot: taskForPrompt != nil && taskForPrompt.Autopilot, IncludeUserQuestionTool: taskForPrompt == nil || !taskForPrompt.Autopilot, IncludeParentQuestionTool: taskForPrompt != nil && taskForPrompt.Autopilot && taskForPrompt.ParentID != "", - }, referenceContext) + }, referenceContext, pullRequestTargetContext) } } userMsgRecorded := s.recordAutoStartMessage(ctx, taskID, sessionID, recordedPrompt, planMode, origin, references) diff --git a/apps/backend/internal/orchestrator/executor/executor_resume.go b/apps/backend/internal/orchestrator/executor/executor_resume.go index c61b60c9b9..06f88e4cf8 100644 --- a/apps/backend/internal/orchestrator/executor/executor_resume.go +++ b/apps/backend/internal/orchestrator/executor/executor_resume.go @@ -221,6 +221,9 @@ func (e *Executor) resolveTaskRepoInfoForSession( info.RepositoryPath = repo.LocalPath info.WorktreeBranchPrefix = repo.WorktreeBranchPrefix info.WorktreeBranchTemplate = repo.WorktreeBranchTemplate + if tr.BranchPolicyBranchTemplate != "" { + info.WorktreeBranchTemplate = tr.BranchPolicyBranchTemplate + } info.PullBeforeWorktree = repo.PullBeforeWorktree if info.BaseBranch == "" && repo.DefaultBranch != "" { info.BaseBranch = repo.DefaultBranch @@ -1432,6 +1435,9 @@ func (e *Executor) applyResumeWorktreeConfig( } req.WorktreeBranchPrefix = repository.WorktreeBranchPrefix req.WorktreeBranchTemplate = repository.WorktreeBranchTemplate + if primaryTaskRepo != nil && primaryTaskRepo.RepositoryID == repositoryID && primaryTaskRepo.BranchPolicyBranchTemplate != "" { + req.WorktreeBranchTemplate = primaryTaskRepo.BranchPolicyBranchTemplate + } req.PullBeforeWorktree = repository.PullBeforeWorktree // Worktree manager requires TaskDirName and RepoName. Mirror the // initial-launch path (applyRepositoryConfig) so resumes of single-repo diff --git a/apps/backend/internal/orchestrator/executor/executor_resume_test.go b/apps/backend/internal/orchestrator/executor/executor_resume_test.go index fd7d37cb31..1250a4ec5c 100644 --- a/apps/backend/internal/orchestrator/executor/executor_resume_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_resume_test.go @@ -1171,6 +1171,47 @@ func TestResolveResumeBaseBranchKeepsLegacyValueWhenRepositoryRowsAreAmbiguous(t } } +func TestResumeUsesTaskRepositoryBranchPolicyTemplateSnapshot(t *testing.T) { + for _, testCase := range []struct { + name string + snapshot string + want string + }{ + {name: "policy snapshot overrides repository template", snapshot: "policy/{title}", want: "policy/{title}"}, + {name: "empty snapshot falls back to repository template", want: "repository/{title}"}, + } { + t.Run(testCase.name, func(t *testing.T) { + repo := newMockRepository() + repo.repositories["repo-1"] = &models.Repository{ + ID: "repo-1", WorkspaceID: "workspace-1", Name: "widgets", SourceType: sourceTypeLocal, + LocalPath: t.TempDir(), WorktreeBranchTemplate: "repository/{title}", + } + repo.taskRepositories["task-repo-1"] = &models.TaskRepository{ + ID: "task-repo-1", TaskID: "task-1", RepositoryID: "repo-1", + BranchPolicyBranchTemplate: testCase.snapshot, + } + exec := newTestExecutor(t, &mockAgentManager{}, repo) + + info, err := exec.resolveTaskRepoInfoForSession(context.Background(), "session-1", repo.taskRepositories["task-repo-1"]) + if err != nil { + t.Fatalf("resolveTaskRepoInfoForSession: %v", err) + } + if info.WorktreeBranchTemplate != testCase.want { + t.Fatalf("resolved worktree template = %q, want %q", info.WorktreeBranchTemplate, testCase.want) + } + + req := &LaunchAgentRequest{} + exec.applyResumeWorktreeConfig( + context.Background(), &v1.Task{ID: "task-1", Title: "Resume task"}, req, + repo.repositories["repo-1"], "repo-1", repo.repositories["repo-1"].LocalPath, "main", nil, + ) + if req.WorktreeBranchTemplate != testCase.want { + t.Fatalf("resume request worktree template = %q, want %q", req.WorktreeBranchTemplate, testCase.want) + } + }) + } +} + func TestApplyResumeWorktreeConfigPreservesSelectedRepositoryDestination(t *testing.T) { primaryDestination := resumeTestContributionDestination("200") selectedDestination := resumeTestContributionDestination("201") diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 737d9d4a64..b432389761 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -473,12 +473,13 @@ func (o *reservedPromptCallbackOwner) stop() { // Service is the main orchestrator service type Service struct { - config ServiceConfig - logger *logger.Logger - eventBus bus.EventBus - taskRepo scheduler.TaskRepository - repo sessionExecutorStore - agentManager executor.AgentManagerClient + config ServiceConfig + logger *logger.Logger + eventBus bus.EventBus + taskRepo scheduler.TaskRepository + repo sessionExecutorStore + promptTargets taskPullRequestTargetStore + agentManager executor.AgentManagerClient // Components queue *queue.TaskQueue @@ -1147,6 +1148,7 @@ func NewService( eventBus: eventBus, taskRepo: taskRepo, repo: repo, + promptTargets: repo, agentManager: agentManager, queue: taskQueue, executor: exec, diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 9aab8147aa..69653d8455 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -666,6 +666,9 @@ func (s *Service) wrapCreatedSessionPrompt( references []v1.EntityReference, promptReferenceContext string, ) string { + prompt, pullRequestTargetContext := s.addTaskPullRequestTargetContext( + ctx, taskID, prompt, session.IsPassthrough, + ) referenceContext := EntityReferenceContext(references) switch { case session.IsPassthrough: @@ -677,7 +680,7 @@ func (s *Service) wrapCreatedSessionPrompt( return sysprompt.InjectOfficeContextWithOptions( taskID, sessionID, prompt, s.WorkflowStepRequiresCompletionSignal(ctx, dbTask.WorkflowStepID), - referenceContext, promptReferenceContext, + referenceContext, promptReferenceContext, pullRequestTargetContext, ) default: return sysprompt.InjectKandevContextWithOptions(taskID, sessionID, prompt, sysprompt.KandevContextOptions{ @@ -687,7 +690,7 @@ func (s *Service) wrapCreatedSessionPrompt( Autopilot: dbTask.Autopilot, IncludeUserQuestionTool: !dbTask.Autopilot && !session.IsPassthrough, IncludeParentQuestionTool: dbTask.Autopilot && dbTask.ParentID != "", - }, referenceContext, promptReferenceContext) + }, referenceContext, promptReferenceContext, pullRequestTargetContext) } } @@ -1241,6 +1244,10 @@ type launchPromptContext struct { // Passthrough profiles get attribution only, as plain text — see // applySpawnOriginText for why they skip the MCP block entirely. func (s *Service) applyLaunchPromptContext(ctx context.Context, p launchPromptContext) string { + var pullRequestTargetContext string + p.prompt, pullRequestTargetContext = s.addTaskPullRequestTargetContext( + ctx, p.taskID, p.prompt, p.isPassthrough, + ) if p.isPassthrough { prompt := applySpawnOriginText(p.prompt, p.spawnOrigin) if p.includeTaskTitleTool { @@ -1257,7 +1264,7 @@ func (s *Service) applyLaunchPromptContext(ctx context.Context, p launchPromptCo return sysprompt.InjectOfficeContextWithOptions( p.taskID, p.sessionID, prompt, s.StepRequiresCompletionSignal(ctx, p.taskID), - p.referenceContext, spawnContext, + p.referenceContext, spawnContext, pullRequestTargetContext, ) } return sysprompt.InjectKandevContextWithOptions(p.taskID, p.sessionID, prompt, sysprompt.KandevContextOptions{ @@ -1267,7 +1274,7 @@ func (s *Service) applyLaunchPromptContext(ctx context.Context, p launchPromptCo Autopilot: p.autopilot, IncludeUserQuestionTool: !p.autopilot && !p.isPassthrough, IncludeParentQuestionTool: p.autopilot && p.includeParentQuestionTool, - }, p.referenceContext, spawnContext) + }, p.referenceContext, spawnContext, pullRequestTargetContext) } // spawnOriginContent renders the spawner-attribution text for a launch requested diff --git a/apps/backend/internal/orchestrator/task_pull_request_target_prompt.go b/apps/backend/internal/orchestrator/task_pull_request_target_prompt.go new file mode 100644 index 0000000000..8f502de5a2 --- /dev/null +++ b/apps/backend/internal/orchestrator/task_pull_request_target_prompt.go @@ -0,0 +1,62 @@ +package orchestrator + +import ( + "context" + + "github.com/kandev/kandev/internal/sysprompt" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/promptcontext" + "go.uber.org/zap" +) + +type taskPullRequestTargetStore interface { + ListTaskRepositories(ctx context.Context, taskID string) ([]*models.TaskRepository, error) + GetRepository(ctx context.Context, id string) (*models.Repository, error) +} + +func (s *Service) taskPullRequestTargets(ctx context.Context, taskID string) []sysprompt.PullRequestTarget { + if s.promptTargets == nil { + return nil + } + links, err := s.promptTargets.ListTaskRepositories(ctx, taskID) + if err != nil { + s.logPullRequestTargetLookupFailure("failed to load task repositories", taskID, "", err) + return nil + } + return promptcontext.BuildPullRequestTargets(ctx, links, func(resolveCtx context.Context, repositoryID string) (string, error) { + repository, err := s.promptTargets.GetRepository(resolveCtx, repositoryID) + if err != nil { + s.logPullRequestTargetLookupFailure( + "failed to load repository for pull request target prompt", taskID, repositoryID, err, + ) + return "", err + } + if repository == nil { + return "", nil + } + return repository.Name, nil + }) +} + +func (s *Service) logPullRequestTargetLookupFailure(message, taskID, repositoryID string, err error) { + if s.logger == nil { + return + } + fields := []zap.Field{zap.String("task_id", taskID), zap.Error(err)} + if repositoryID != "" { + fields = append(fields, zap.String("repository_id", repositoryID)) + } + s.logger.Warn(message, fields...) +} + +func (s *Service) addTaskPullRequestTargetContext( + ctx context.Context, + taskID, prompt string, + passthrough bool, +) (string, string) { + targets := s.taskPullRequestTargets(ctx, taskID) + if passthrough { + return sysprompt.PrependPullRequestTargetInstruction(prompt, targets), "" + } + return sysprompt.InjectPullRequestTargetContext(prompt, targets) +} diff --git a/apps/backend/internal/orchestrator/task_pull_request_target_prompt_test.go b/apps/backend/internal/orchestrator/task_pull_request_target_prompt_test.go new file mode 100644 index 0000000000..a3ba25e36c --- /dev/null +++ b/apps/backend/internal/orchestrator/task_pull_request_target_prompt_test.go @@ -0,0 +1,137 @@ +package orchestrator + +import ( + "context" + "strings" + "testing" + + "github.com/kandev/kandev/internal/sysprompt" + "github.com/kandev/kandev/internal/task/models" + wfmodels "github.com/kandev/kandev/internal/workflow/models" + v1 "github.com/kandev/kandev/pkg/api/v1" + "github.com/stretchr/testify/require" +) + +// @covers AC-WORKSPACES-BRANCH-POLICIES-004.6 +func TestStartCreatedSession_InstructsAgentToUseSnapshottedPullRequestTarget(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateCreated) + seedExecutorRunning(t, repo, "session1", "task1", "exec-1") + seedPolicyBackedTaskRepository(t, repo, "task1") + + taskRepo := newMockTaskRepo() + taskRepo.tasks["task1"] = &v1.Task{ID: "task1", Title: "Release", State: v1.TaskStateInProgress} + agentMgr := &mockAgentManager{repoForExecutionLookup: repo} + svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, agentMgr) + svc.promptTargets = repo + messages := &mockMessageCreator{} + svc.messageCreator = messages + + _, err := svc.StartCreatedSession( + ctx, "task1", "session1", "profile1", "Create the release", false, false, false, nil, nil, + ) + require.NoError(t, err) + require.Len(t, messages.userMessages, 1) + + content := messages.userMessages[0].content + require.Contains(t, content, "BRANCH POLICY PULL REQUEST TARGETS") + require.Contains(t, content, `Repository "kandev"`) + require.Contains(t, content, `working branch "release/next"`) + require.Contains(t, content, `target branch "main"`) + require.Contains(t, content, `gh pr create --base "main"`) + require.Equal(t, "Create the release", sysprompt.StripSystemContent(content)) +} + +// @covers AC-WORKSPACES-BRANCH-POLICIES-004.6 +func TestStartCreatedSession_GivesPassthroughAgentPullRequestTargetInstruction(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateCreated) + seedExecutorRunning(t, repo, "session1", "task1", "exec-1") + seedPolicyBackedTaskRepository(t, repo, "task1") + + session, err := repo.GetTaskSession(ctx, "session1") + require.NoError(t, err) + session.IsPassthrough = true + require.NoError(t, repo.UpdateTaskSession(ctx, session)) + + taskRepo := newMockTaskRepo() + taskRepo.tasks["task1"] = &v1.Task{ID: "task1", Title: "Release", State: v1.TaskStateInProgress} + agentMgr := &mockAgentManager{repoForExecutionLookup: repo} + svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, agentMgr) + svc.promptTargets = repo + messages := &mockMessageCreator{} + svc.messageCreator = messages + + _, err = svc.StartCreatedSession( + ctx, "task1", "session1", "profile1", "Create the release", false, false, false, nil, nil, + ) + require.NoError(t, err) + require.Len(t, messages.userMessages, 1) + + content := messages.userMessages[0].content + require.NotContains(t, content, sysprompt.TagStart) + require.True(t, strings.HasSuffix(content, "Create the release")) + require.Contains(t, content, `gh pr create --base "main"`) +} + +// @covers AC-WORKSPACES-BRANCH-POLICIES-004.6 +func TestAutoStartStepPrompt_ResetContextReinjectsSnapshottedPullRequestTarget(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput) + seedPolicyBackedTaskRepository(t, repo, "task1") + + session, err := repo.GetTaskSession(ctx, "session1") + require.NoError(t, err) + session.AgentExecutionID = "execution-1" + require.NoError(t, repo.UpdateTaskSession(ctx, session)) + seedExecutorRunning(t, repo, session.ID, session.TaskID, session.AgentExecutionID) + + step := &wfmodels.WorkflowStep{ + ID: "step-review", WorkflowID: "wf1", Name: "Review", + Events: wfmodels.StepEvents{OnEnter: []wfmodels.OnEnterAction{ + {Type: wfmodels.OnEnterResetAgentContext}, + {Type: wfmodels.OnEnterAutoStartAgent}, + }}, + } + stepGetter := newMockStepGetter() + stepGetter.steps[step.ID] = step + agentMgr := &mockAgentManager{repoForExecutionLookup: repo, isAgentRunning: true} + svc := createTestServiceWithScheduler(repo, stepGetter, newMockTaskRepo(), agentMgr) + svc.promptTargets = repo + messages := &mockMessageCreator{} + svc.messageCreator = messages + + require.NoError(t, svc.autoStartStepPrompt( + ctx, "task1", session, step, "Review the release", false, false, + )) + require.Len(t, messages.userMessages, 1) + require.Contains(t, messages.userMessages[0].content, `gh pr create --base "main"`) + require.Len(t, agentMgr.capturedPromptCalls, 1) + require.Contains(t, agentMgr.capturedPromptCalls[0].Prompt, `gh pr create --base "main"`) +} + +func seedPolicyBackedTaskRepository(t *testing.T, repo interface { + CreateRepository(context.Context, *models.Repository) error + CreateTaskRepository(context.Context, *models.TaskRepository) error +}, taskID string) { + t.Helper() + ctx := context.Background() + require.NoError(t, repo.CreateRepository(ctx, &models.Repository{ + ID: "repo-1", WorkspaceID: "ws1", Name: "kandev", SourceType: "local", + })) + require.NoError(t, repo.CreateTaskRepository(ctx, &models.TaskRepository{ + ID: "task-repo-1", + TaskID: taskID, + RepositoryID: "repo-1", + BaseBranch: "develop", + CheckoutBranch: "release/next", + BranchPolicyID: "policy-release", + BranchPolicyName: "Release", + BranchPolicyBaseBranch: "develop", + BranchPolicyBranchTemplate: "release/{title}-{suffix}", + BranchPolicyPullRequestTarget: "main", + })) +} diff --git a/apps/backend/internal/orchestrator/task_title_branch.go b/apps/backend/internal/orchestrator/task_title_branch.go index 31b22b7159..edc2a95950 100644 --- a/apps/backend/internal/orchestrator/task_title_branch.go +++ b/apps/backend/internal/orchestrator/task_title_branch.go @@ -49,15 +49,25 @@ type TitleBranchRenameResult struct { Failed []TitleBranchFailure `json:"failed,omitempty"` } -func renderTitleBranchName(title string, task *models.Task, repository *models.Repository, suffix string) (string, error) { +func renderTitleBranchNameForTaskRepository( + title string, + task *models.Task, + repository *models.Repository, + taskRepository *models.TaskRepository, + suffix string, +) (string, error) { if task == nil { return "", fmt.Errorf("task is required") } if repository == nil { return "", fmt.Errorf("repository is required") } + template := repository.WorktreeBranchTemplate + if taskRepository != nil && taskRepository.BranchPolicyBranchTemplate != "" { + template = taskRepository.BranchPolicyBranchTemplate + } return worktree.RenderTaskBranchName(worktree.BranchNameTemplateInput{ - Template: repository.WorktreeBranchTemplate, + Template: template, TaskID: task.ID, Title: title, Ticket: worktree.TicketForBranchName(task.Identifier, task.Metadata), diff --git a/apps/backend/internal/orchestrator/task_title_branch_runtime.go b/apps/backend/internal/orchestrator/task_title_branch_runtime.go index 3e74d62e05..2ba3c49c47 100644 --- a/apps/backend/internal/orchestrator/task_title_branch_runtime.go +++ b/apps/backend/internal/orchestrator/task_title_branch_runtime.go @@ -375,7 +375,7 @@ func (s *Service) performTitleBranchRename( branch string, ) (string, error) { suffix := titleBranchSuffix(executorType, task.ID) - newName, err := renderTitleBranchName(title, task, binding.repository, suffix) + newName, err := renderTitleBranchNameForTaskRepository(title, task, binding.repository, binding.taskRepository, suffix) if err != nil { return "", err } diff --git a/apps/backend/internal/orchestrator/task_title_branch_test.go b/apps/backend/internal/orchestrator/task_title_branch_test.go index aaa9798631..70b68f4b67 100644 --- a/apps/backend/internal/orchestrator/task_title_branch_test.go +++ b/apps/backend/internal/orchestrator/task_title_branch_test.go @@ -7,17 +7,34 @@ import ( ) func TestRenderTitleBranchNameUsesFinalTitleAndRepositoryTemplate(t *testing.T) { - got, err := renderTitleBranchName( + got, err := renderTitleBranchNameForTaskRepository( "Improve login flow", &models.Task{ID: "task-123", Identifier: "KAN-42"}, &models.Repository{WorktreeBranchTemplate: "feature/{ticket}-{title}-{suffix}"}, + nil, "abc", ) if err != nil { - t.Fatalf("renderTitleBranchName returned error: %v", err) + t.Fatalf("renderTitleBranchNameForTaskRepository returned error: %v", err) } if got != "feature/kan-42-improve-login-flow-abc" { - t.Fatalf("renderTitleBranchName = %q, want final-title branch", got) + t.Fatalf("renderTitleBranchNameForTaskRepository = %q, want final-title branch", got) + } +} + +func TestRenderTitleBranchNameUsesTaskPolicySnapshot(t *testing.T) { + got, err := renderTitleBranchNameForTaskRepository( + "Fix login flow", + &models.Task{ID: "task-123", Identifier: "KAN-42"}, + &models.Repository{WorktreeBranchTemplate: "feature/{title}"}, + &models.TaskRepository{BranchPolicyBranchTemplate: "bugfix/{ticket}-{title}-{suffix}"}, + "abc", + ) + if err != nil { + t.Fatalf("renderTitleBranchNameForTaskRepository returned error: %v", err) + } + if got != "bugfix/kan-42-fix-login-flow-abc" { + t.Fatalf("renderTitleBranchNameForTaskRepository = %q, want policy snapshot branch", got) } } diff --git a/apps/backend/internal/sysprompt/sysprompt.go b/apps/backend/internal/sysprompt/sysprompt.go index 103b2e2118..add780c43e 100644 --- a/apps/backend/internal/sysprompt/sysprompt.go +++ b/apps/backend/internal/sysprompt/sysprompt.go @@ -68,6 +68,70 @@ const kandevContextMarker = "KANDEV MCP TOOLS" const officeContextMarker = "KANDEV OFFICE MCP TOOLS" +const pullRequestTargetContextMarker = "BRANCH POLICY PULL REQUEST TARGETS" + +// PullRequestTarget is immutable task context that tells an agent where a +// branch-policy-backed pull request must merge. +type PullRequestTarget struct { + RepositoryName string + WorkingBranch string + TargetBranch string +} + +// FormatPullRequestTargetContext formats branch-policy targets as trusted +// agent instructions. Values are quoted so repository configuration remains +// data even when it contains punctuation. +func FormatPullRequestTargetContext(targets []PullRequestTarget) string { + if len(targets) == 0 { + return "" + } + lines := []string{ + pullRequestTargetContextMarker + ":", + "This task uses immutable branch-policy targets.", + "When you create a pull request, use the matching target explicitly.", + "Do not infer the target from the task base or working branch.", + } + for _, target := range targets { + lines = append(lines, fmt.Sprintf( + "- Repository %s, working branch %s: target branch %s. For GitHub, use gh pr create --base %s.", + strconv.Quote(StripTags(target.RepositoryName)), + strconv.Quote(StripTags(target.WorkingBranch)), + strconv.Quote(StripTags(target.TargetBranch)), + strconv.Quote(StripTags(target.TargetBranch)), + )) + } + return strings.Join(lines, "\n") +} + +// InjectPullRequestTargetContext prepends one canonical hidden target block. +// It removes a stale or upstream copy before inserting the current snapshot. +func InjectPullRequestTargetContext(prompt string, targets []PullRequestTarget) (string, string) { + prompt = systemTagRegex.ReplaceAllStringFunc(prompt, func(block string) string { + if strings.Contains(block, pullRequestTargetContextMarker) { + return "" + } + return block + }) + content := FormatPullRequestTargetContext(targets) + if content == "" { + return prompt, "" + } + return Wrap(content) + "\n\n" + prompt, content +} + +// PrependPullRequestTargetInstruction adds the compact plain-text equivalent +// for passthrough sessions, which intentionally do not receive hidden blocks. +func PrependPullRequestTargetInstruction(prompt string, targets []PullRequestTarget) string { + content := FormatPullRequestTargetContext(targets) + if content == "" { + return prompt + } + if strings.HasPrefix(prompt, content+"\n\n") { + return prompt + } + return content + "\n\n" + prompt +} + type contextKind uint8 const ( diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 0760656f71..c0daeaffd6 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -96,6 +96,18 @@ type RepositorySetItemDTO struct { Position int `json:"position"` } +type RepositoryBranchPolicyDTO struct { + ID string `json:"id"` + RepositoryID string `json:"repository_id"` + Name string `json:"name"` + Description string `json:"description"` + BaseBranch string `json:"base_branch"` + BranchTemplate string `json:"branch_template"` + PullRequestTarget string `json:"pull_request_target"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type RepositoryScriptDTO struct { ID string `json:"id"` RepositoryID string `json:"repository_id"` @@ -264,15 +276,20 @@ type TaskDTO struct { } type TaskRepositoryDTO struct { - ID string `json:"id"` - TaskID string `json:"task_id"` - RepositoryID string `json:"repository_id"` - BaseBranch string `json:"base_branch"` - CheckoutBranch string `json:"checkout_branch,omitempty"` - Position int `json:"position"` - Metadata map[string]interface{} `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + TaskID string `json:"task_id"` + RepositoryID string `json:"repository_id"` + BaseBranch string `json:"base_branch"` + CheckoutBranch string `json:"checkout_branch,omitempty"` + BranchPolicyID string `json:"branch_policy_id,omitempty"` + BranchPolicyName string `json:"branch_policy_name,omitempty"` + BranchPolicyBaseBranch string `json:"branch_policy_base_branch,omitempty"` + BranchPolicyBranchTemplate string `json:"branch_policy_branch_template,omitempty"` + BranchPolicyPullRequestTarget string `json:"branch_policy_pull_request_target,omitempty"` + Position int `json:"position"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // TaskWorkspaceFolderDTO is the API projection of a durable non-Git source. @@ -516,6 +533,11 @@ type ListRepositorySetsResponse struct { Total int `json:"total"` } +type ListRepositoryBranchPoliciesResponse struct { + Policies []RepositoryBranchPolicyDTO `json:"repository_branch_policies"` + Total int `json:"total"` +} + type ListRepositoryScriptsResponse struct { Scripts []RepositoryScriptDTO `json:"scripts"` Total int `json:"total"` @@ -689,6 +711,15 @@ func FromRepositorySet(set *models.RepositorySet) RepositorySetDTO { } } +func FromRepositoryBranchPolicy(policy *models.RepositoryBranchPolicy) RepositoryBranchPolicyDTO { + return RepositoryBranchPolicyDTO{ + ID: policy.ID, RepositoryID: policy.RepositoryID, Name: policy.Name, + Description: policy.Description, BaseBranch: policy.BaseBranch, + BranchTemplate: policy.BranchTemplate, PullRequestTarget: policy.PullRequestTarget, + CreatedAt: policy.CreatedAt, UpdatedAt: policy.UpdatedAt, + } +} + func FromRepositoryScript(script *models.RepositoryScript) RepositoryScriptDTO { return RepositoryScriptDTO{ ID: script.ID, @@ -791,15 +822,20 @@ func FromTaskWithSessionInfo( var repositories []TaskRepositoryDTO for _, repo := range task.Repositories { repositories = append(repositories, TaskRepositoryDTO{ - ID: repo.ID, - TaskID: repo.TaskID, - RepositoryID: repo.RepositoryID, - BaseBranch: repo.BaseBranch, - CheckoutBranch: repo.CheckoutBranch, - Position: repo.Position, - Metadata: repo.Metadata, - CreatedAt: repo.CreatedAt, - UpdatedAt: repo.UpdatedAt, + ID: repo.ID, + TaskID: repo.TaskID, + RepositoryID: repo.RepositoryID, + BaseBranch: repo.BaseBranch, + CheckoutBranch: repo.CheckoutBranch, + BranchPolicyID: repo.BranchPolicyID, + BranchPolicyName: repo.BranchPolicyName, + BranchPolicyBaseBranch: repo.BranchPolicyBaseBranch, + BranchPolicyBranchTemplate: repo.BranchPolicyBranchTemplate, + BranchPolicyPullRequestTarget: repo.BranchPolicyPullRequestTarget, + Position: repo.Position, + Metadata: repo.Metadata, + CreatedAt: repo.CreatedAt, + UpdatedAt: repo.UpdatedAt, }) } var workspaceFolders []TaskWorkspaceFolderDTO diff --git a/apps/backend/internal/task/dto/requests.go b/apps/backend/internal/task/dto/requests.go index d57b71c2e4..e511b35a77 100644 --- a/apps/backend/internal/task/dto/requests.go +++ b/apps/backend/internal/task/dto/requests.go @@ -258,6 +258,7 @@ type TaskRepositoryInput struct { RepositoryID string BaseBranch string CheckoutBranch string + BranchPolicyID string PRNumber int // GitHub PR number when CheckoutBranch is a PR head; persisted into task_repositories.metadata["pr_number"]. LocalPath string Name string @@ -268,6 +269,10 @@ type TaskRepositoryInput struct { ProviderRepoID string ProviderOwner string ProviderName string + // PreserveBaseBranch is set only by the internal fresh-branch rewrite. It + // keeps the generated branch as the effective base when the association is + // recreated after policy resolution. + PreserveBaseBranch bool } type CreateTaskRequest struct { diff --git a/apps/backend/internal/task/handlers/errors.go b/apps/backend/internal/task/handlers/errors.go index 4ba00ebc46..dbdd3a6646 100644 --- a/apps/backend/internal/task/handlers/errors.go +++ b/apps/backend/internal/task/handlers/errors.go @@ -31,13 +31,28 @@ func handleNotFound(c *gin.Context, log *logger.Logger, err error, fallback stri return } if isValidationError(err) { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + c.JSON(http.StatusBadRequest, taskErrorBody(err)) return } log.Error("request failed", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "request failed"}) } +func taskErrorBody(err error) gin.H { + body := gin.H{"error": err.Error()} + for key, value := range taskErrorDetails(err) { + body[key] = value + } + return body +} + +func taskErrorDetails(err error) map[string]interface{} { + if errors.Is(err, service.ErrRepositoryBranchPolicyStale) { + return map[string]interface{}{"error_code": service.BranchPolicyStaleErrorCode} + } + return nil +} + func handleSelectedMoveError(c *gin.Context, log *logger.Logger, err error) { switch { case isClientDisconnect(err): diff --git a/apps/backend/internal/task/handlers/errors_test.go b/apps/backend/internal/task/handlers/errors_test.go index 2a87f76255..f54f8a7b85 100644 --- a/apps/backend/internal/task/handlers/errors_test.go +++ b/apps/backend/internal/task/handlers/errors_test.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "encoding/json" "errors" "fmt" "net" @@ -93,6 +94,25 @@ func TestErrorsAreClassifiable(t *testing.T) { t.Fatalf("status=%d, want %d", rec.Code, http.StatusConflict) } }) + + t.Run("stale branch policies include a stable error code", func(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + err := fmt.Errorf("%w: %w", service.ErrInvalidRepositoryBranchPolicy, service.ErrRepositoryBranchPolicyStale) + handleNotFound(ctx, newTestLogger(t), err, "task not created") + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status=%d, want %d", rec.Code, http.StatusBadRequest) + } + var payload map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload["error_code"] != service.BranchPolicyStaleErrorCode { + t.Fatalf("error_code=%q, want %q", payload["error_code"], service.BranchPolicyStaleErrorCode) + } + }) } // TestIsTimeoutError pins the UX-classification contract for the prompt diff --git a/apps/backend/internal/task/handlers/message_handlers.go b/apps/backend/internal/task/handlers/message_handlers.go index b6187e7264..add38ec0fd 100644 --- a/apps/backend/internal/task/handlers/message_handlers.go +++ b/apps/backend/internal/task/handlers/message_handlers.go @@ -445,8 +445,15 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w } requiresSignal := h.orchestrator != nil && h.orchestrator.StepRequiresCompletionSignal(ctx, req.TaskID) referenceContext := orchestrator.EntityReferenceContext(req.EntityReferences) + var pullRequestTargetContext string + storedContent, pullRequestTargetContext = sysprompt.InjectPullRequestTargetContext( + storedContent, h.taskPullRequestTargets(ctx, task), + ) if task.IsFromOffice { - storedContent = sysprompt.InjectOfficeContextWithOptions(req.TaskID, req.TaskSessionID, storedContent, requiresSignal, referenceContext) + storedContent = sysprompt.InjectOfficeContextWithOptions( + req.TaskID, req.TaskSessionID, storedContent, requiresSignal, + referenceContext, pullRequestTargetContext, + ) } else { storedContent = sysprompt.InjectKandevContextWithOptions(req.TaskID, req.TaskSessionID, storedContent, sysprompt.KandevContextOptions{ RequiresCompletionSignal: requiresSignal, @@ -455,7 +462,7 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w Autopilot: task.Autopilot, IncludeUserQuestionTool: !task.Autopilot && !sessionResp.Session.IsPassthrough, IncludeParentQuestionTool: task.Autopilot && task.ParentID != "", - }, referenceContext) + }, referenceContext, pullRequestTargetContext) } } req.Content = storedContent diff --git a/apps/backend/internal/task/handlers/message_handlers_branch_policy_prompt_test.go b/apps/backend/internal/task/handlers/message_handlers_branch_policy_prompt_test.go new file mode 100644 index 0000000000..2dd611e52f --- /dev/null +++ b/apps/backend/internal/task/handlers/message_handlers_branch_policy_prompt_test.go @@ -0,0 +1,85 @@ +package handlers + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/service" + v1 "github.com/kandev/kandev/pkg/api/v1" + ws "github.com/kandev/kandev/pkg/websocket" + "github.com/stretchr/testify/require" +) + +type policyPromptMessageRepo struct { + *messageAddSwitchRepo + taskRepositories []*models.TaskRepository + repositories map[string]*models.Repository +} + +func (r *policyPromptMessageRepo) ListTaskRepositories( + _ context.Context, + _ string, +) ([]*models.TaskRepository, error) { + return r.taskRepositories, nil +} + +func (r *policyPromptMessageRepo) GetRepository( + _ context.Context, + id string, +) (*models.Repository, error) { + return r.repositories[id], nil +} + +// @covers AC-WORKSPACES-BRANCH-POLICIES-004.6 +func TestWSAddMessage_PersistsSnapshottedPullRequestTargetContext(t *testing.T) { + now := time.Now().UTC() + base := &messageAddSwitchRepo{ + tasks: map[string]*models.Task{"t1": { + ID: "t1", WorkspaceID: "ws1", State: v1.TaskStateInProgress, UpdatedAt: now, + }}, + sessions: map[string]*models.TaskSession{"s1": { + ID: "s1", TaskID: "t1", State: models.TaskSessionStateCreated, + AgentProfileID: "profile-1", UpdatedAt: now, + }}, + primaryID: "s1", + } + repo := &policyPromptMessageRepo{ + messageAddSwitchRepo: base, + taskRepositories: []*models.TaskRepository{{ + ID: "task-repo-1", + TaskID: "t1", + RepositoryID: "repo-1", + BaseBranch: "develop", + CheckoutBranch: "release/next", + BranchPolicyID: "policy-release", + BranchPolicyPullRequestTarget: "main", + }}, + repositories: map[string]*models.Repository{ + "repo-1": {ID: "repo-1", WorkspaceID: "ws1", Name: "kandev"}, + }, + } + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + require.NoError(t, err) + svc := service.NewService(service.Repos{ + Workspaces: repo, Tasks: repo, TaskRepos: repo, + Workflows: repo, Messages: repo, Turns: repo, + Sessions: repo, GitSnapshots: repo, RepoEntities: repo, + Executors: repo, Environments: repo, TaskEnvironments: repo, + Reviews: repo, + }, nil, log, service.RepositoryDiscoveryConfig{}) + orch := &firstTurnCaptureOrchestrator{started: make(chan capturedFirstTurn, 1)} + handler := NewMessageHandlers(svc, orch, log) + + req, err := ws.NewRequest("req-policy-target", ws.ActionMessageAdd, map[string]any{ + "task_id": "t1", "session_id": "s1", "content": "Create the release", + }) + require.NoError(t, err) + response, err := handler.wsAddMessage(context.Background(), req) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, response.Type) + require.Len(t, base.messages, 1) + require.Contains(t, base.messages[0].Content, `gh pr create --base "main"`) +} diff --git a/apps/backend/internal/task/handlers/message_pull_request_target_prompt.go b/apps/backend/internal/task/handlers/message_pull_request_target_prompt.go new file mode 100644 index 0000000000..531d260c54 --- /dev/null +++ b/apps/backend/internal/task/handlers/message_pull_request_target_prompt.go @@ -0,0 +1,25 @@ +package handlers + +import ( + "context" + + "github.com/kandev/kandev/internal/sysprompt" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/promptcontext" +) + +func (h *MessageHandlers) taskPullRequestTargets( + ctx context.Context, + task *models.Task, +) []sysprompt.PullRequestTarget { + return promptcontext.BuildPullRequestTargets(ctx, task.Repositories, func(resolveCtx context.Context, repositoryID string) (string, error) { + repository, err := h.service.GetRepository(resolveCtx, repositoryID) + if err != nil { + return "", err + } + if repository == nil { + return "", nil + } + return repository.Name, nil + }) +} diff --git a/apps/backend/internal/task/handlers/repository_branch_policy_handlers.go b/apps/backend/internal/task/handlers/repository_branch_policy_handlers.go new file mode 100644 index 0000000000..c0e6b4b40b --- /dev/null +++ b/apps/backend/internal/task/handlers/repository_branch_policy_handlers.go @@ -0,0 +1,303 @@ +package handlers + +import ( + "context" + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/dto" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" + "github.com/kandev/kandev/internal/task/service" + ws "github.com/kandev/kandev/pkg/websocket" +) + +type RepositoryBranchPolicyHandlers struct { + service *service.Service + logger *logger.Logger +} + +func NewRepositoryBranchPolicyHandlers(svc *service.Service, log *logger.Logger) *RepositoryBranchPolicyHandlers { + return &RepositoryBranchPolicyHandlers{service: svc, logger: log.WithFields(zap.String("component", "task-repository-branch-policy-handlers"))} +} + +func RegisterRepositoryBranchPolicyRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, svc *service.Service, log *logger.Logger) { + h := NewRepositoryBranchPolicyHandlers(svc, log) + h.registerHTTP(router) + h.registerWS(dispatcher) +} + +func (h *RepositoryBranchPolicyHandlers) registerHTTP(router *gin.Engine) { + api := router.Group("/api/v1") + api.GET("/repositories/:id/branch-policies", h.httpList) + api.POST("/repositories/:id/branch-policies", h.httpCreate) + api.POST("/repositories/:id/branch-policies/gitflow", h.httpGitflow) + api.GET("/repository-branch-policies/:id", h.httpGet) + api.PATCH("/repository-branch-policies/:id", h.httpUpdate) + api.DELETE("/repository-branch-policies/:id", h.httpDelete) +} + +func (h *RepositoryBranchPolicyHandlers) registerWS(dispatcher *ws.Dispatcher) { + dispatcher.RegisterFunc(ws.ActionRepositoryBranchPolicyList, h.wsList) + dispatcher.RegisterFunc(ws.ActionRepositoryBranchPolicyCreate, h.wsCreate) + dispatcher.RegisterFunc(ws.ActionRepositoryBranchPolicyGet, h.wsGet) + dispatcher.RegisterFunc(ws.ActionRepositoryBranchPolicyUpdate, h.wsUpdate) + dispatcher.RegisterFunc(ws.ActionRepositoryBranchPolicyDelete, h.wsDelete) + dispatcher.RegisterFunc(ws.ActionRepositoryBranchPolicyGitflow, h.wsGitflow) +} + +type repositoryBranchPolicyCreateBody struct { + Name string `json:"name"` + Description string `json:"description"` + BaseBranch string `json:"base_branch"` + BranchTemplate string `json:"branch_template"` + PullRequestTarget string `json:"pull_request_target"` +} + +type repositoryBranchPolicyUpdateBody struct { + Name *string `json:"name"` + Description *string `json:"description"` + BaseBranch *string `json:"base_branch"` + BranchTemplate *string `json:"branch_template"` + PullRequestTarget *string `json:"pull_request_target"` +} + +type repositoryBranchPolicyGitflowBody struct { + ProductionBranch string `json:"production_branch"` + DevelopmentBranch string `json:"development_branch"` +} + +func branchPolicyListResponse(policies []*models.RepositoryBranchPolicy) dto.ListRepositoryBranchPoliciesResponse { + items := make([]dto.RepositoryBranchPolicyDTO, 0, len(policies)) + for _, policy := range policies { + if policy != nil { + items = append(items, dto.FromRepositoryBranchPolicy(policy)) + } + } + return dto.ListRepositoryBranchPoliciesResponse{Policies: items, Total: len(items)} +} + +func branchPolicyStatus(err error) int { + switch { + case errors.Is(err, service.ErrInvalidRepositoryBranchPolicy): + return http.StatusBadRequest + case errors.Is(err, service.ErrRepositoryBranchPolicyNameConflict), + errors.Is(err, service.ErrRepositoryBranchPolicyAlreadySeeded), + errors.Is(err, service.ErrRepositoryBranchPolicyReadOnly): + return http.StatusConflict + case errors.Is(err, repoerrors.ErrRepositoryNotFound), + errors.Is(err, repoerrors.ErrRepositoryBranchPolicyNotFound), + errors.Is(err, repoerrors.ErrWorkspaceNotFound): + return http.StatusNotFound + default: + return http.StatusInternalServerError + } +} + +func (h *RepositoryBranchPolicyHandlers) abort(c *gin.Context, action string, err error) { + status := branchPolicyStatus(err) + if status == http.StatusInternalServerError { + h.logger.Error("repository branch policy request failed", zap.String("action", action), zap.Error(err)) + c.JSON(status, gin.H{"error": "failed to " + action}) + return + } + c.JSON(status, gin.H{"error": err.Error()}) +} + +func (h *RepositoryBranchPolicyHandlers) httpList(c *gin.Context) { + policies, err := h.service.ListRepositoryBranchPolicies(c.Request.Context(), c.Param("id")) + if err != nil { + h.abort(c, "list repository branch policies", err) + return + } + c.JSON(http.StatusOK, branchPolicyListResponse(policies)) +} + +func (h *RepositoryBranchPolicyHandlers) httpCreate(c *gin.Context) { + var body repositoryBranchPolicyCreateBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + policy, err := h.service.CreateRepositoryBranchPolicy(c.Request.Context(), &service.CreateRepositoryBranchPolicyRequest{ + RepositoryID: c.Param("id"), Name: body.Name, Description: body.Description, + BaseBranch: body.BaseBranch, BranchTemplate: body.BranchTemplate, PullRequestTarget: body.PullRequestTarget, + }) + if err != nil { + h.abort(c, "create repository branch policy", err) + return + } + c.JSON(http.StatusCreated, dto.FromRepositoryBranchPolicy(policy)) +} + +func (h *RepositoryBranchPolicyHandlers) httpGitflow(c *gin.Context) { + var body repositoryBranchPolicyGitflowBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + policies, err := h.service.CreateGitflowRepositoryBranchPolicies(c.Request.Context(), &service.CreateGitflowRepositoryBranchPoliciesRequest{ + RepositoryID: c.Param("id"), ProductionBranch: body.ProductionBranch, DevelopmentBranch: body.DevelopmentBranch, + }) + if err != nil { + h.abort(c, "seed Gitflow repository branch policies", err) + return + } + c.JSON(http.StatusCreated, branchPolicyListResponse(policies)) +} + +func (h *RepositoryBranchPolicyHandlers) httpGet(c *gin.Context) { + policy, err := h.service.GetRepositoryBranchPolicy(c.Request.Context(), c.Param("id")) + if err != nil { + h.abort(c, "get repository branch policy", err) + return + } + c.JSON(http.StatusOK, dto.FromRepositoryBranchPolicy(policy)) +} + +func (h *RepositoryBranchPolicyHandlers) httpUpdate(c *gin.Context) { + var body repositoryBranchPolicyUpdateBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + policy, err := h.service.GetRepositoryBranchPolicy(c.Request.Context(), c.Param("id")) + if err != nil { + h.abort(c, "get repository branch policy", err) + return + } + updated, err := h.service.UpdateRepositoryBranchPolicy(c.Request.Context(), policy.ID, &service.UpdateRepositoryBranchPolicyRequest{ + Name: body.Name, Description: body.Description, BaseBranch: body.BaseBranch, + BranchTemplate: body.BranchTemplate, PullRequestTarget: body.PullRequestTarget, + }) + if err != nil { + h.abort(c, "update repository branch policy", err) + return + } + c.JSON(http.StatusOK, dto.FromRepositoryBranchPolicy(updated)) +} + +func (h *RepositoryBranchPolicyHandlers) httpDelete(c *gin.Context) { + policy, err := h.service.GetRepositoryBranchPolicy(c.Request.Context(), c.Param("id")) + if err != nil { + h.abort(c, "get repository branch policy", err) + return + } + if err := h.service.DeleteRepositoryBranchPolicy(c.Request.Context(), policy.ID); err != nil { + h.abort(c, "delete repository branch policy", err) + return + } + c.Status(http.StatusNoContent) +} + +func (h *RepositoryBranchPolicyHandlers) wsError(msg *ws.Message, action string, err error) (*ws.Message, error) { + switch branchPolicyStatus(err) { + case http.StatusBadRequest: + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), taskErrorDetails(err)) + case http.StatusConflict: + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeConflict, err.Error(), taskErrorDetails(err)) + case http.StatusNotFound: + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, err.Error(), taskErrorDetails(err)) + default: + h.logger.Error("repository branch policy request failed", zap.String("action", action), zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to "+action, nil) + } +} + +func (h *RepositoryBranchPolicyHandlers) wsList(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + RepositoryID string `json:"repository_id"` + } + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + policies, err := h.service.ListRepositoryBranchPolicies(ctx, req.RepositoryID) + if err != nil { + return h.wsError(msg, "list repository branch policies", err) + } + return ws.NewResponse(msg.ID, msg.Action, branchPolicyListResponse(policies)) +} + +func (h *RepositoryBranchPolicyHandlers) wsCreate(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + RepositoryID string `json:"repository_id"` + repositoryBranchPolicyCreateBody + } + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + policy, err := h.service.CreateRepositoryBranchPolicy(ctx, &service.CreateRepositoryBranchPolicyRequest{ + RepositoryID: req.RepositoryID, Name: req.Name, Description: req.Description, + BaseBranch: req.BaseBranch, BranchTemplate: req.BranchTemplate, PullRequestTarget: req.PullRequestTarget, + }) + if err != nil { + return h.wsError(msg, "create repository branch policy", err) + } + return ws.NewResponse(msg.ID, msg.Action, dto.FromRepositoryBranchPolicy(policy)) +} + +func (h *RepositoryBranchPolicyHandlers) wsGet(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + ID string `json:"id"` + } + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + policy, err := h.service.GetRepositoryBranchPolicy(ctx, req.ID) + if err != nil { + return h.wsError(msg, "get repository branch policy", err) + } + return ws.NewResponse(msg.ID, msg.Action, dto.FromRepositoryBranchPolicy(policy)) +} + +func (h *RepositoryBranchPolicyHandlers) wsUpdate(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + ID string `json:"id"` + repositoryBranchPolicyUpdateBody + } + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + updated, err := h.service.UpdateRepositoryBranchPolicy(ctx, req.ID, &service.UpdateRepositoryBranchPolicyRequest{ + Name: req.Name, Description: req.Description, BaseBranch: req.BaseBranch, + BranchTemplate: req.BranchTemplate, PullRequestTarget: req.PullRequestTarget, + }) + if err != nil { + return h.wsError(msg, "update repository branch policy", err) + } + return ws.NewResponse(msg.ID, msg.Action, dto.FromRepositoryBranchPolicy(updated)) +} + +func (h *RepositoryBranchPolicyHandlers) wsDelete(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + ID string `json:"id"` + } + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if err := h.service.DeleteRepositoryBranchPolicy(ctx, req.ID); err != nil { + return h.wsError(msg, "delete repository branch policy", err) + } + return ws.NewResponse(msg.ID, msg.Action, gin.H{"id": req.ID, "deleted": true}) +} + +func (h *RepositoryBranchPolicyHandlers) wsGitflow(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + RepositoryID string `json:"repository_id"` + repositoryBranchPolicyGitflowBody + } + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + policies, err := h.service.CreateGitflowRepositoryBranchPolicies(ctx, &service.CreateGitflowRepositoryBranchPoliciesRequest{ + RepositoryID: req.RepositoryID, ProductionBranch: req.ProductionBranch, DevelopmentBranch: req.DevelopmentBranch, + }) + if err != nil { + return h.wsError(msg, "seed Gitflow repository branch policies", err) + } + return ws.NewResponse(msg.ID, msg.Action, branchPolicyListResponse(policies)) +} diff --git a/apps/backend/internal/task/handlers/repository_branch_policy_handlers_test.go b/apps/backend/internal/task/handlers/repository_branch_policy_handlers_test.go new file mode 100644 index 0000000000..ea6c91ef0b --- /dev/null +++ b/apps/backend/internal/task/handlers/repository_branch_policy_handlers_test.go @@ -0,0 +1,249 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/task/dto" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository" + taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite" + "github.com/kandev/kandev/internal/task/service" + ws "github.com/kandev/kandev/pkg/websocket" +) + +func newRepositoryBranchPolicyTestRouter(t *testing.T, workspaceName string) (*gin.Engine, *ws.Dispatcher, *taskrepo.Repository) { + t.Helper() + gin.SetMode(gin.TestMode) + dbConn, err := db.OpenSQLite(filepath.Join(t.TempDir(), "branch-policies.db")) + require.NoError(t, err) + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + repo, cleanup, err := repository.Provide(sqlxDB, sqlxDB, nil) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, cleanup()) + require.NoError(t, sqlxDB.Close()) + }) + + ctx := context.Background() + require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-policy-handler", Name: workspaceName})) + repositoryPath := initBranchPolicyGitRepository(t) + require.NoError(t, repo.CreateRepository(ctx, &models.Repository{ + ID: "repo-policy-handler", WorkspaceID: "ws-policy-handler", Name: "policy-repo", + SourceType: "local", LocalPath: repositoryPath, DefaultBranch: "main", + })) + + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"}) + require.NoError(t, err) + svc := service.NewService(service.Repos{ + Workspaces: repo, RepoEntities: repo, BranchPolicies: repo, + }, bus.NewMemoryEventBus(log), log, service.RepositoryDiscoveryConfig{}) + router := gin.New() + dispatcher := ws.NewDispatcher() + RegisterRepositoryBranchPolicyRoutes(router, dispatcher, svc, log) + return router, dispatcher, repo +} + +func initBranchPolicyGitRepository(t *testing.T) string { + t.Helper() + repositoryPath := filepath.Join(t.TempDir(), "repository") + require.NoError(t, os.MkdirAll(repositoryPath, 0o755)) + runGit := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = repositoryPath + cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1") + output, err := cmd.CombinedOutput() + require.NoErrorf(t, err, "git %v failed: %s", args, output) + } + runGit("init", "-b", "main") + require.NoError(t, os.WriteFile(filepath.Join(repositoryPath, "README.md"), []byte("branch policy test\n"), 0o644)) + runGit("config", "user.email", "test@example.com") + runGit("config", "user.name", "Kandev Tests") + runGit("add", "README.md") + runGit("commit", "-m", "initial") + runGit("branch", "develop") + return repositoryPath +} + +func seedHandlerBranchPolicy(t *testing.T, repo *taskrepo.Repository) *models.RepositoryBranchPolicy { + t.Helper() + policy := &models.RepositoryBranchPolicy{ + ID: "policy-handler-existing", RepositoryID: "repo-policy-handler", Name: "Existing", + Description: "Existing policy", BaseBranch: "main", BranchTemplate: "feature/{title}-{suffix}", + PullRequestTarget: "main", + } + require.NoError(t, repo.CreateRepositoryBranchPolicy(context.Background(), policy)) + return policy +} + +func dispatchBranchPolicyAction(t *testing.T, dispatcher *ws.Dispatcher, action string, payload any) *ws.Message { + t.Helper() + message, err := ws.NewRequest("branch-policy-test", action, payload) + require.NoError(t, err) + response, err := dispatcher.Dispatch(context.Background(), message) + require.NoError(t, err) + require.NotNil(t, response) + return response +} + +func requireBranchPolicyWSConflict(t *testing.T, response *ws.Message) { + t.Helper() + require.Equal(t, ws.MessageTypeError, response.Type) + var payload ws.ErrorPayload + require.NoError(t, json.Unmarshal(response.Payload, &payload)) + require.Equal(t, ws.ErrorCodeConflict, payload.Code) + require.Contains(t, payload.Message, "Improve Kandev") +} + +func TestRepositoryBranchPolicyHTTPHandlersCoverCRUDAndStatusMappings(t *testing.T) { + router, _, _ := newRepositoryBranchPolicyTestRouter(t, "Policy workspace") + + list := doJSON(t, router, http.MethodGet, "/api/v1/repositories/repo-policy-handler/branch-policies", nil) + require.Equal(t, http.StatusOK, list.Code, list.Body.String()) + var empty dto.ListRepositoryBranchPoliciesResponse + require.NoError(t, json.Unmarshal(list.Body.Bytes(), &empty)) + require.Empty(t, empty.Policies) + + createdResponse := doJSON(t, router, http.MethodPost, "/api/v1/repositories/repo-policy-handler/branch-policies", map[string]any{ + "name": "Feature", "description": "Feature branches", "base_branch": "develop", + "branch_template": "feature/{title}-{suffix}", "pull_request_target": "develop", + }) + require.Equal(t, http.StatusCreated, createdResponse.Code, createdResponse.Body.String()) + var created dto.RepositoryBranchPolicyDTO + require.NoError(t, json.Unmarshal(createdResponse.Body.Bytes(), &created)) + require.Equal(t, "Feature", created.Name) + require.Equal(t, "develop", created.PullRequestTarget) + + get := doJSON(t, router, http.MethodGet, "/api/v1/repository-branch-policies/"+created.ID, nil) + require.Equal(t, http.StatusOK, get.Code, get.Body.String()) + + updated := doJSON(t, router, http.MethodPatch, "/api/v1/repository-branch-policies/"+created.ID, map[string]any{ + "description": "Updated description", "pull_request_target": "main", + }) + require.Equal(t, http.StatusOK, updated.Code, updated.Body.String()) + var updatedPolicy dto.RepositoryBranchPolicyDTO + require.NoError(t, json.Unmarshal(updated.Body.Bytes(), &updatedPolicy)) + require.Equal(t, "Updated description", updatedPolicy.Description) + require.Equal(t, "main", updatedPolicy.PullRequestTarget) + + duplicate := doJSON(t, router, http.MethodPost, "/api/v1/repositories/repo-policy-handler/branch-policies", map[string]any{ + "name": "feature", "base_branch": "main", "branch_template": "feature/{title}-{suffix}", + "pull_request_target": "main", + }) + require.Equal(t, http.StatusConflict, duplicate.Code, duplicate.Body.String()) + + invalid := doJSON(t, router, http.MethodPost, "/api/v1/repositories/repo-policy-handler/branch-policies", []string{"invalid"}) + require.Equal(t, http.StatusBadRequest, invalid.Code, invalid.Body.String()) + + missing := doJSON(t, router, http.MethodGet, "/api/v1/repository-branch-policies/missing", nil) + require.Equal(t, http.StatusNotFound, missing.Code, missing.Body.String()) + + deleted := doJSON(t, router, http.MethodDelete, "/api/v1/repository-branch-policies/"+created.ID, nil) + require.Equal(t, http.StatusNoContent, deleted.Code, deleted.Body.String()) +} + +func TestRepositoryBranchPolicyHTTPGitflowMapsConflicts(t *testing.T) { + router, _, _ := newRepositoryBranchPolicyTestRouter(t, "Policy workspace") + body := map[string]any{"production_branch": "main", "development_branch": "develop"} + + created := doJSON(t, router, http.MethodPost, "/api/v1/repositories/repo-policy-handler/branch-policies/gitflow", body) + require.Equal(t, http.StatusCreated, created.Code, created.Body.String()) + var response dto.ListRepositoryBranchPoliciesResponse + require.NoError(t, json.Unmarshal(created.Body.Bytes(), &response)) + require.Len(t, response.Policies, 4) + + conflict := doJSON(t, router, http.MethodPost, "/api/v1/repositories/repo-policy-handler/branch-policies/gitflow", body) + require.Equal(t, http.StatusConflict, conflict.Code, conflict.Body.String()) +} + +func TestRepositoryBranchPolicyWSHandlersCoverCRUDAndGitflow(t *testing.T) { + _, dispatcher, _ := newRepositoryBranchPolicyTestRouter(t, "Policy workspace") + createPayload := map[string]any{ + "repository_id": "repo-policy-handler", "name": "Feature", "description": "Feature branches", + "base_branch": "develop", "branch_template": "feature/{title}-{suffix}", "pull_request_target": "develop", + } + created := dispatchBranchPolicyAction(t, dispatcher, ws.ActionRepositoryBranchPolicyCreate, createPayload) + require.NotEqual(t, ws.MessageTypeError, created.Type, string(created.Payload)) + var policy dto.RepositoryBranchPolicyDTO + require.NoError(t, json.Unmarshal(created.Payload, &policy)) + + list := dispatchBranchPolicyAction(t, dispatcher, ws.ActionRepositoryBranchPolicyList, map[string]any{"repository_id": "repo-policy-handler"}) + require.NotEqual(t, ws.MessageTypeError, list.Type, string(list.Payload)) + get := dispatchBranchPolicyAction(t, dispatcher, ws.ActionRepositoryBranchPolicyGet, map[string]any{"id": policy.ID}) + require.NotEqual(t, ws.MessageTypeError, get.Type, string(get.Payload)) + update := dispatchBranchPolicyAction(t, dispatcher, ws.ActionRepositoryBranchPolicyUpdate, map[string]any{ + "id": policy.ID, "description": "Updated", "pull_request_target": "main", + }) + require.NotEqual(t, ws.MessageTypeError, update.Type, string(update.Payload)) + deleted := dispatchBranchPolicyAction(t, dispatcher, ws.ActionRepositoryBranchPolicyDelete, map[string]any{"id": policy.ID}) + require.NotEqual(t, ws.MessageTypeError, deleted.Type, string(deleted.Payload)) + + gitflow := dispatchBranchPolicyAction(t, dispatcher, ws.ActionRepositoryBranchPolicyGitflow, map[string]any{ + "repository_id": "repo-policy-handler", "production_branch": "main", "development_branch": "develop", + }) + require.NotEqual(t, ws.MessageTypeError, gitflow.Type, string(gitflow.Payload)) + gitflowConflict := dispatchBranchPolicyAction(t, dispatcher, ws.ActionRepositoryBranchPolicyGitflow, map[string]any{ + "repository_id": "repo-policy-handler", "production_branch": "main", "development_branch": "develop", + }) + require.Equal(t, ws.MessageTypeError, gitflowConflict.Type) + var conflictPayload ws.ErrorPayload + require.NoError(t, json.Unmarshal(gitflowConflict.Payload, &conflictPayload)) + require.Equal(t, ws.ErrorCodeConflict, conflictPayload.Code) +} + +func TestRepositoryBranchPolicyHandlersRejectImproveWorkspaceMutations(t *testing.T) { + router, dispatcher, repo := newRepositoryBranchPolicyTestRouter(t, models.WorkspaceNameImproveKandev) + policy := seedHandlerBranchPolicy(t, repo) + + create := doJSON(t, router, http.MethodPost, "/api/v1/repositories/repo-policy-handler/branch-policies", map[string]any{ + "name": "Blocked", "base_branch": "main", "branch_template": "feature/{title}-{suffix}", + "pull_request_target": "main", + }) + require.Equal(t, http.StatusConflict, create.Code, create.Body.String()) + + update := doJSON(t, router, http.MethodPatch, "/api/v1/repository-branch-policies/"+policy.ID, map[string]any{"name": "Renamed"}) + require.Equal(t, http.StatusConflict, update.Code, update.Body.String()) + delete := doJSON(t, router, http.MethodDelete, "/api/v1/repository-branch-policies/"+policy.ID, nil) + require.Equal(t, http.StatusConflict, delete.Code, delete.Body.String()) + + cases := []struct { + name string + action string + payload any + }{ + {"create", ws.ActionRepositoryBranchPolicyCreate, map[string]any{ + "repository_id": "repo-policy-handler", "name": "Blocked", "base_branch": "main", + "branch_template": "feature/{title}-{suffix}", "pull_request_target": "main", + }}, + {"update", ws.ActionRepositoryBranchPolicyUpdate, map[string]any{"id": policy.ID, "name": "Renamed"}}, + {"delete", ws.ActionRepositoryBranchPolicyDelete, map[string]any{"id": policy.ID}}, + {"gitflow", ws.ActionRepositoryBranchPolicyGitflow, map[string]any{ + "repository_id": "repo-policy-handler", "production_branch": "main", "development_branch": "develop", + }}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + requireBranchPolicyWSConflict(t, dispatchBranchPolicyAction(t, dispatcher, testCase.action, testCase.payload)) + }) + } + + list := doJSON(t, router, http.MethodGet, "/api/v1/repositories/repo-policy-handler/branch-policies", nil) + require.Equal(t, http.StatusOK, list.Code, list.Body.String()) + var policies dto.ListRepositoryBranchPoliciesResponse + require.NoError(t, json.Unmarshal(list.Body.Bytes(), &policies)) + require.Len(t, policies.Policies, 1) + require.Equal(t, policy.ID, policies.Policies[0].ID) +} diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index ff10fb75c2..118246be3c 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -226,19 +226,21 @@ func convertToServiceRepos(repos []dto.TaskRepositoryInput) []service.TaskReposi result := make([]service.TaskRepositoryInput, len(repos)) for i, r := range repos { result[i] = service.TaskRepositoryInput{ - RepositoryID: r.RepositoryID, - BaseBranch: r.BaseBranch, - CheckoutBranch: r.CheckoutBranch, - PRNumber: r.PRNumber, - LocalPath: r.LocalPath, - Name: r.Name, - DefaultBranch: r.DefaultBranch, - GitHubURL: r.GitHubURL, - RemoteURL: r.RemoteURL, - Provider: r.Provider, - ProviderRepoID: r.ProviderRepoID, - ProviderOwner: r.ProviderOwner, - ProviderName: r.ProviderName, + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + CheckoutBranch: r.CheckoutBranch, + BranchPolicyID: r.BranchPolicyID, + PRNumber: r.PRNumber, + LocalPath: r.LocalPath, + Name: r.Name, + DefaultBranch: r.DefaultBranch, + GitHubURL: r.GitHubURL, + RemoteURL: r.RemoteURL, + Provider: r.Provider, + ProviderRepoID: r.ProviderRepoID, + ProviderOwner: r.ProviderOwner, + ProviderName: r.ProviderName, + PreserveBaseBranch: r.PreserveBaseBranch, } } return result diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index 0f40594151..ee992aadec 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -693,6 +693,7 @@ type httpTaskRepositoryInput struct { RepositoryID string `json:"repository_id"` BaseBranch string `json:"base_branch"` CheckoutBranch string `json:"checkout_branch"` + BranchPolicyID string `json:"branch_policy_id,omitempty"` PRNumber int `json:"pr_number,omitempty"` LocalPath string `json:"local_path"` Name string `json:"name"` @@ -1197,7 +1198,7 @@ func (h *TaskHandlers) commitFreshBranch( c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve task repository"}) return false } - if !h.applyFreshBranch(c, title, inputs, repos, task.Repositories) { + if !h.applyFreshBranch(c, title, task, inputs, repos, task.Repositories) { h.rollbackFreshBranchTask(c.Request.Context(), taskID) return false } @@ -1237,6 +1238,7 @@ func (h *TaskHandlers) rollbackFreshBranchTask(ctx context.Context, taskID strin func (h *TaskHandlers) applyFreshBranch( c *gin.Context, taskTitle string, + task *models.Task, inputs []httpTaskRepositoryInput, repos []dto.TaskRepositoryInput, persisted []*models.TaskRepository, @@ -1256,7 +1258,7 @@ func (h *TaskHandlers) applyFreshBranch( // User didn't pick one — fall back to the repo's checked-out branch. baseBranch, _ = h.service.RepositoryCurrentBranch(ctx, repositoryID) } - newBranch := resolveFreshBranchName(raw.NewBranchName, taskTitle) + newBranch := resolveFreshBranchNameForTask(raw.NewBranchName, taskTitle, task, persisted[i]) err := h.service.PerformFreshBranch(ctx, service.FreshBranchRequest{ RepositoryID: repositoryID, BaseBranch: baseBranch, @@ -1270,6 +1272,7 @@ func (h *TaskHandlers) applyFreshBranch( } repos[i].BaseBranch = newBranch repos[i].CheckoutBranch = "" + repos[i].PreserveBaseBranch = true } return true } @@ -1286,6 +1289,25 @@ func resolveFreshBranchName(rawNewBranch, taskTitle string) string { return worktree.SemanticWorktreeName(taskTitle, worktree.SmallSuffix(3)) } +func resolveFreshBranchNameForTask(rawNewBranch, taskTitle string, task *models.Task, taskRepository *models.TaskRepository) string { + if name := strings.TrimSpace(rawNewBranch); name != "" { + return name + } + if task != nil && taskRepository != nil && taskRepository.BranchPolicyBranchTemplate != "" { + branch, err := worktree.RenderTaskBranchName(worktree.BranchNameTemplateInput{ + Template: taskRepository.BranchPolicyBranchTemplate, + TaskID: task.ID, + Title: taskTitle, + Ticket: worktree.TicketForBranchName(task.Identifier, task.Metadata), + Suffix: worktree.SmallSuffix(3), + }) + if err == nil { + return branch + } + } + return resolveFreshBranchName("", taskTitle) +} + func (h *TaskHandlers) respondFreshBranchError(c *gin.Context, err error) { var dirty *service.ErrDirtyWorkingTree if errors.As(err, &dirty) { @@ -1332,6 +1354,7 @@ func convertCreateTaskRepositories(c *gin.Context, inputs []httpTaskRepositoryIn RepositoryID: r.RepositoryID, BaseBranch: r.BaseBranch, CheckoutBranch: r.CheckoutBranch, + BranchPolicyID: r.BranchPolicyID, PRNumber: r.PRNumber, LocalPath: r.LocalPath, Name: r.Name, @@ -1559,6 +1582,7 @@ func (h *TaskHandlers) httpUpdateTask(c *gin.Context) { RepositoryID: r.RepositoryID, BaseBranch: r.BaseBranch, CheckoutBranch: r.CheckoutBranch, + BranchPolicyID: r.BranchPolicyID, PRNumber: r.PRNumber, LocalPath: r.LocalPath, Name: r.Name, diff --git a/apps/backend/internal/task/handlers/task_http_handlers_test.go b/apps/backend/internal/task/handlers/task_http_handlers_test.go index bd7e92cf6e..2227b8f0bb 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers_test.go +++ b/apps/backend/internal/task/handlers/task_http_handlers_test.go @@ -1657,6 +1657,22 @@ func TestResolveFreshBranchName(t *testing.T) { } } +func TestResolveFreshBranchNameForTaskUsesPolicySnapshot(t *testing.T) { + task := &models.Task{ + ID: "task-123", + Identifier: "KAN-7", + Metadata: map[string]interface{}{}, + } + taskRepository := &models.TaskRepository{ + BranchPolicyBranchTemplate: "bugfix/{ticket}-{title}-{suffix}", + } + + got := resolveFreshBranchNameForTask("", "Fix login", task, taskRepository) + if !strings.HasPrefix(got, "bugfix/kan-7-fix-login-") { + t.Fatalf("policy branch = %q, want bugfix/kan-7-fix-login-*", got) + } +} + type freshBranchIdentityRepository struct { mockRepository task *models.Task diff --git a/apps/backend/internal/task/handlers/task_ws_handlers.go b/apps/backend/internal/task/handlers/task_ws_handlers.go index e173e00034..842869dc3f 100644 --- a/apps/backend/internal/task/handlers/task_ws_handlers.go +++ b/apps/backend/internal/task/handlers/task_ws_handlers.go @@ -133,6 +133,7 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M RepositoryID: r.RepositoryID, BaseBranch: r.BaseBranch, CheckoutBranch: r.CheckoutBranch, + BranchPolicyID: r.BranchPolicyID, PRNumber: r.PRNumber, LocalPath: r.LocalPath, Name: r.Name, @@ -195,7 +196,7 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeConflict, err.Error(), nil) } if isTaskCreateValidationError(err) { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), taskErrorDetails(err)) } return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create task", nil) } @@ -301,6 +302,7 @@ func (h *TaskHandlers) wsUpdateTask(ctx context.Context, msg *ws.Message) (*ws.M RepositoryID: r.RepositoryID, BaseBranch: r.BaseBranch, CheckoutBranch: r.CheckoutBranch, + BranchPolicyID: r.BranchPolicyID, PRNumber: r.PRNumber, LocalPath: r.LocalPath, Name: r.Name, diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 596ed71170..c1fa59bae1 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -1020,15 +1020,20 @@ type Workspace struct { // TaskRepository represents a repository associated with a task type TaskRepository struct { - ID string `json:"id"` - TaskID string `json:"task_id"` - RepositoryID string `json:"repository_id"` - BaseBranch string `json:"base_branch"` - CheckoutBranch string `json:"checkout_branch,omitempty"` - Position int `json:"position"` - Metadata map[string]interface{} `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + TaskID string `json:"task_id"` + RepositoryID string `json:"repository_id"` + BaseBranch string `json:"base_branch"` + CheckoutBranch string `json:"checkout_branch,omitempty"` + BranchPolicyID string `json:"branch_policy_id,omitempty"` + BranchPolicyName string `json:"branch_policy_name,omitempty"` + BranchPolicyBaseBranch string `json:"branch_policy_base_branch,omitempty"` + BranchPolicyBranchTemplate string `json:"branch_policy_branch_template,omitempty"` + BranchPolicyPullRequestTarget string `json:"branch_policy_pull_request_target,omitempty"` + Position int `json:"position"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // TaskWorkspaceFolder is a canonical host-folder attachment owned by a task. @@ -1656,6 +1661,21 @@ type Repository struct { DeletedAt *time.Time `json:"deleted_at,omitempty"` } +// RepositoryBranchPolicy is a reusable branch workflow owned by one repository. +// It is configuration, not task history: task repositories copy these fields +// into their snapshot columns when a policy is selected. +type RepositoryBranchPolicy struct { + ID string `json:"id"` + RepositoryID string `json:"repository_id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + BaseBranch string `json:"base_branch"` + BranchTemplate string `json:"branch_template"` + PullRequestTarget string `json:"pull_request_target"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + // RepositorySet is a named, reusable group of workspace repositories that fills // the task-creation repository picker in one action. // diff --git a/apps/backend/internal/task/promptcontext/pull_request_targets.go b/apps/backend/internal/task/promptcontext/pull_request_targets.go new file mode 100644 index 0000000000..cf4123f6e1 --- /dev/null +++ b/apps/backend/internal/task/promptcontext/pull_request_targets.go @@ -0,0 +1,43 @@ +package promptcontext + +import ( + "context" + + "github.com/kandev/kandev/internal/sysprompt" + "github.com/kandev/kandev/internal/task/models" +) + +// RepositoryNameResolver resolves a repository's display name for prompt context. +// An error leaves the repository ID as the stable fallback name. +type RepositoryNameResolver func(context.Context, string) (string, error) + +// BuildPullRequestTargets converts task repository snapshots into the prompt +// context used by both task orchestration and task message handlers. +func BuildPullRequestTargets( + ctx context.Context, + links []*models.TaskRepository, + resolveRepositoryName RepositoryNameResolver, +) []sysprompt.PullRequestTarget { + targets := make([]sysprompt.PullRequestTarget, 0, len(links)) + for _, link := range links { + if link == nil || link.BranchPolicyID == "" || link.BranchPolicyPullRequestTarget == "" { + continue + } + repositoryName := link.RepositoryID + if resolveRepositoryName != nil { + if name, err := resolveRepositoryName(ctx, link.RepositoryID); err == nil && name != "" { + repositoryName = name + } + } + workingBranch := link.CheckoutBranch + if workingBranch == "" { + workingBranch = link.BaseBranch + } + targets = append(targets, sysprompt.PullRequestTarget{ + RepositoryName: repositoryName, + WorkingBranch: workingBranch, + TargetBranch: link.BranchPolicyPullRequestTarget, + }) + } + return targets +} diff --git a/apps/backend/internal/task/promptcontext/pull_request_targets_test.go b/apps/backend/internal/task/promptcontext/pull_request_targets_test.go new file mode 100644 index 0000000000..a920d3c1e9 --- /dev/null +++ b/apps/backend/internal/task/promptcontext/pull_request_targets_test.go @@ -0,0 +1,33 @@ +package promptcontext + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/sysprompt" + "github.com/kandev/kandev/internal/task/models" + "github.com/stretchr/testify/require" +) + +func TestBuildPullRequestTargets(t *testing.T) { + links := []*models.TaskRepository{ + {RepositoryID: "repo-1", BaseBranch: "develop", CheckoutBranch: "feature/task", BranchPolicyID: "policy-1", BranchPolicyPullRequestTarget: "main"}, + {RepositoryID: "repo-2", BaseBranch: "main", BranchPolicyID: "policy-2", BranchPolicyPullRequestTarget: "release"}, + {RepositoryID: "repo-3", BranchPolicyID: "policy-3"}, + {RepositoryID: "repo-4", BranchPolicyPullRequestTarget: "main"}, + nil, + } + + targets := BuildPullRequestTargets(context.Background(), links, func(_ context.Context, repositoryID string) (string, error) { + if repositoryID == "repo-2" { + return "", errors.New("repository unavailable") + } + return "repo-1-name", nil + }) + + require.Equal(t, []sysprompt.PullRequestTarget{ + {RepositoryName: "repo-1-name", WorkingBranch: "feature/task", TargetBranch: "main"}, + {RepositoryName: "repo-2", WorkingBranch: "main", TargetBranch: "release"}, + }, targets) +} diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index 9713e89a92..7ac8733993 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -446,6 +446,19 @@ type RepositorySetRepository interface { DeleteRepositorySet(ctx context.Context, id string) (bool, error) } +// RepositoryBranchPolicyRepository stores reusable branch workflows owned by +// a repository. The batch method is an atomic, one-time Gitflow starter. +type RepositoryBranchPolicyRepository interface { + CreateRepositoryBranchPolicy(ctx context.Context, policy *models.RepositoryBranchPolicy) error + GetRepositoryBranchPolicy(ctx context.Context, id string) (*models.RepositoryBranchPolicy, error) + GetRepositoryBranchPolicyByName(ctx context.Context, repositoryID, name string) (*models.RepositoryBranchPolicy, error) + ListRepositoryBranchPolicies(ctx context.Context, repositoryID string) ([]*models.RepositoryBranchPolicy, error) + ListRepositoryBranchPoliciesByWorkspace(ctx context.Context, workspaceID string) ([]*models.RepositoryBranchPolicy, error) + UpdateRepositoryBranchPolicy(ctx context.Context, policy *models.RepositoryBranchPolicy) error + DeleteRepositoryBranchPolicy(ctx context.Context, id string) (bool, error) + CreateRepositoryBranchPoliciesIfEmpty(ctx context.Context, repositoryID string, policies []*models.RepositoryBranchPolicy) error +} + // RepositorySecretBindingRepository stores normalized repository environment // references. It is optional on RepositoryEntityRepository to keep legacy // adapters source-compatible while the SQLite implementation rolls out. diff --git a/apps/backend/internal/task/repository/repoerrors/errors.go b/apps/backend/internal/task/repository/repoerrors/errors.go index 5951f38dd7..31f0c5229c 100644 --- a/apps/backend/internal/task/repository/repoerrors/errors.go +++ b/apps/backend/internal/task/repository/repoerrors/errors.go @@ -25,6 +25,13 @@ var ErrRepositoryNotFound = errors.New("repository not found") // ErrRepositorySetNotFound reports that no repository set row matched the supplied id. var ErrRepositorySetNotFound = errors.New("repository set not found") +// ErrRepositoryBranchPolicyNotFound reports that no policy row matched the id. +var ErrRepositoryBranchPolicyNotFound = errors.New("repository branch policy not found") + +// ErrRepositoryBranchPoliciesExist reports that a Gitflow starter cannot seed +// a repository that already has one or more policies. +var ErrRepositoryBranchPoliciesExist = errors.New("repository branch policies already exist") + // ErrTaskEnvironmentNotFound reports that no task environment row matched the supplied id. var ErrTaskEnvironmentNotFound = errors.New("task environment not found") diff --git a/apps/backend/internal/task/repository/sqlite/base_migrations.go b/apps/backend/internal/task/repository/sqlite/base_migrations.go index c31add7db9..ed3f84f67c 100644 --- a/apps/backend/internal/task/repository/sqlite/base_migrations.go +++ b/apps/backend/internal/task/repository/sqlite/base_migrations.go @@ -60,6 +60,9 @@ func (r *Repository) runMigrations() error { if err := r.ensureRepositorySetsSchema(); err != nil { return err } + if err := r.ensureRepositoryBranchPoliciesSchema(); err != nil { + return err + } r.migrate.Apply("task_sessions.execution_profile_id", `ALTER TABLE task_sessions ADD COLUMN execution_profile_id TEXT NOT NULL DEFAULT ''`) r.migrate.Apply("task_sessions.route_generation", `ALTER TABLE task_sessions ADD COLUMN route_generation INTEGER NOT NULL DEFAULT 0`) r.migrate.Apply("task_sessions.route_state", `ALTER TABLE task_sessions ADD COLUMN route_state TEXT NOT NULL DEFAULT ''`) @@ -77,6 +80,11 @@ func (r *Repository) runMigrations() error { r.migrate.Apply("executors_running.local_pid", `ALTER TABLE executors_running ADD COLUMN local_pid INTEGER DEFAULT 0`) r.migrate.Apply("tasks.is_ephemeral", `ALTER TABLE tasks ADD COLUMN is_ephemeral INTEGER NOT NULL DEFAULT 0`) r.migrate.Apply("task_repositories.checkout_branch", `ALTER TABLE task_repositories ADD COLUMN checkout_branch TEXT DEFAULT ''`) + r.migrate.Apply("task_repositories.branch_policy_id", `ALTER TABLE task_repositories ADD COLUMN branch_policy_id TEXT DEFAULT ''`) + r.migrate.Apply("task_repositories.branch_policy_name", `ALTER TABLE task_repositories ADD COLUMN branch_policy_name TEXT DEFAULT ''`) + r.migrate.Apply("task_repositories.branch_policy_base_branch", `ALTER TABLE task_repositories ADD COLUMN branch_policy_base_branch TEXT DEFAULT ''`) + r.migrate.Apply("task_repositories.branch_policy_branch_template", `ALTER TABLE task_repositories ADD COLUMN branch_policy_branch_template TEXT DEFAULT ''`) + r.migrate.Apply("task_repositories.branch_policy_pull_request_target", `ALTER TABLE task_repositories ADD COLUMN branch_policy_pull_request_target TEXT DEFAULT ''`) // Multi-branch support: drop the old UNIQUE(task_id, repository_id) and // replace it with UNIQUE(task_id, repository_id, checkout_branch) so the // same repo can appear multiple times in a task on different branches. @@ -590,6 +598,15 @@ func (r *Repository) ensureRepositorySetsSchema() error { return nil } +// ensureRepositoryBranchPoliciesSchema replays the policy DDL for databases +// created before repository branch policies existed. +func (r *Repository) ensureRepositoryBranchPoliciesSchema() error { + if _, err := r.db.Exec(repositoryBranchPoliciesSchemaDDL); err != nil { + return fmt.Errorf("create repository branch policies schema: %w", err) + } + return nil +} + // recreateTable checks whether tableName's DDL contains triggerPhrase and, if so, // runs statements inside a transaction with FK enforcement disabled. // This is the standard SQLite pattern for dropping columns or FK constraints, @@ -1058,6 +1075,11 @@ func (r *Repository) recreateTaskRepositoriesForMultiBranch(trigger string) erro repository_id TEXT NOT NULL, base_branch TEXT DEFAULT '', checkout_branch TEXT DEFAULT '', + branch_policy_id TEXT DEFAULT '', + branch_policy_name TEXT DEFAULT '', + branch_policy_base_branch TEXT DEFAULT '', + branch_policy_branch_template TEXT DEFAULT '', + branch_policy_pull_request_target TEXT DEFAULT '', position INTEGER DEFAULT 0, metadata TEXT DEFAULT '{}', created_at TIMESTAMP NOT NULL, @@ -1069,6 +1091,9 @@ func (r *Repository) recreateTaskRepositoriesForMultiBranch(trigger string) erro `INSERT INTO task_repositories_new SELECT id, task_id, repository_id, base_branch, COALESCE(checkout_branch, ''), + COALESCE(branch_policy_id, ''), COALESCE(branch_policy_name, ''), + COALESCE(branch_policy_base_branch, ''), COALESCE(branch_policy_branch_template, ''), + COALESCE(branch_policy_pull_request_target, ''), position, metadata, created_at, updated_at FROM task_repositories`, `DROP TABLE task_repositories`, diff --git a/apps/backend/internal/task/repository/sqlite/base_schema.go b/apps/backend/internal/task/repository/sqlite/base_schema.go index cc3358279c..5a605a6b32 100644 --- a/apps/backend/internal/task/repository/sqlite/base_schema.go +++ b/apps/backend/internal/task/repository/sqlite/base_schema.go @@ -17,6 +17,7 @@ func (r *Repository) initSchema() error { steps := []func() error{ r.initCoreSchema, r.initRepositorySetsSchema, + r.initRepositoryBranchPoliciesSchema, r.initPlansSchema, r.initWalkthroughsSchema, r.initDocumentsSchema, @@ -424,6 +425,11 @@ func (r *Repository) initTaskSchema() error { repository_id TEXT NOT NULL, base_branch TEXT DEFAULT '', checkout_branch TEXT DEFAULT '', + branch_policy_id TEXT DEFAULT '', + branch_policy_name TEXT DEFAULT '', + branch_policy_base_branch TEXT DEFAULT '', + branch_policy_branch_template TEXT DEFAULT '', + branch_policy_pull_request_target TEXT DEFAULT '', position INTEGER DEFAULT 0, metadata TEXT DEFAULT '{}', created_at TIMESTAMP NOT NULL, @@ -510,6 +516,30 @@ func (r *Repository) initRepositorySetsSchema() error { return err } +const repositoryBranchPoliciesSchemaDDL = ` + CREATE TABLE IF NOT EXISTS repository_branch_policies ( + id TEXT PRIMARY KEY, + repository_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + base_branch TEXT NOT NULL, + branch_template TEXT NOT NULL, + pull_request_target TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_repository_branch_policies_repository_id + ON repository_branch_policies(repository_id); + CREATE UNIQUE INDEX IF NOT EXISTS uniq_repository_branch_policies_repository_lower_name + ON repository_branch_policies(repository_id, LOWER(name)); +` + +func (r *Repository) initRepositoryBranchPoliciesSchema() error { + _, err := r.db.Exec(repositoryBranchPoliciesSchemaDDL) + return err +} + func (r *Repository) initCoreIndexes() error { _, err := r.db.Exec(` CREATE INDEX IF NOT EXISTS idx_tasks_workflow_id ON tasks(workflow_id); diff --git a/apps/backend/internal/task/repository/sqlite/relationship_crud_coverage_test.go b/apps/backend/internal/task/repository/sqlite/relationship_crud_coverage_test.go index 19ac4e8798..9c4b3bd1ec 100644 --- a/apps/backend/internal/task/repository/sqlite/relationship_crud_coverage_test.go +++ b/apps/backend/internal/task/repository/sqlite/relationship_crud_coverage_test.go @@ -90,7 +90,7 @@ func TestTaskRepositoryCRUDOrderingBatchAndCascade(t *testing.T) { } } links := []*models.TaskRepository{ - {ID: "link-second", TaskID: "task-repo-one", RepositoryID: "repo-two", BaseBranch: "main", CheckoutBranch: "feature", Position: 2, Metadata: map[string]any{"role": "secondary"}}, + {ID: "link-second", TaskID: "task-repo-one", RepositoryID: "repo-two", BaseBranch: "main", CheckoutBranch: "feature", BranchPolicyID: "policy-feature", BranchPolicyName: "Feature", BranchPolicyBaseBranch: "main", BranchPolicyBranchTemplate: "feature/{title}-{suffix}", BranchPolicyPullRequestTarget: "develop", Position: 2, Metadata: map[string]any{"role": "secondary"}}, {ID: "link-first", TaskID: "task-repo-one", RepositoryID: "repo-one", BaseBranch: "main", Position: 1, Metadata: map[string]any{"role": "primary"}}, {ID: "link-other", TaskID: "task-repo-two", RepositoryID: "repo-one", Position: 0}, } @@ -100,14 +100,23 @@ func TestTaskRepositoryCRUDOrderingBatchAndCascade(t *testing.T) { } } got, err := repo.GetTaskRepository(ctx, "link-second") - if err != nil || got.CheckoutBranch != "feature" || got.Metadata["role"] != "secondary" { + if err != nil || got.CheckoutBranch != "feature" || got.Metadata["role"] != "secondary" || got.BranchPolicyID != "policy-feature" || got.BranchPolicyName != "Feature" || got.BranchPolicyBaseBranch != "main" || got.BranchPolicyBranchTemplate != "feature/{title}-{suffix}" || got.BranchPolicyPullRequestTarget != "develop" { t.Fatalf("GetTaskRepository = %+v, %v", got, err) } got.Position, got.BaseBranch, got.CheckoutBranch = 0, "develop", "next" + got.BranchPolicyID = "policy-hotfix" + got.BranchPolicyName = "Hotfix" + got.BranchPolicyBaseBranch = "develop" + got.BranchPolicyBranchTemplate = "hotfix/{title}" + got.BranchPolicyPullRequestTarget = "main" got.Metadata = map[string]any{"updated": true} if err := repo.UpdateTaskRepository(ctx, got); err != nil { t.Fatalf("UpdateTaskRepository: %v", err) } + updated, err := repo.GetTaskRepository(ctx, "link-second") + if err != nil || updated.BranchPolicyID != "policy-hotfix" || updated.BranchPolicyName != "Hotfix" || updated.BranchPolicyBaseBranch != "develop" || updated.BranchPolicyBranchTemplate != "hotfix/{title}" || updated.BranchPolicyPullRequestTarget != "main" { + t.Fatalf("updated task repository snapshot = %+v, %v", updated, err) + } primary, err := repo.GetPrimaryTaskRepository(ctx, "task-repo-one") if err != nil || primary.ID != "link-second" { t.Fatalf("GetPrimaryTaskRepository = %+v, %v", primary, err) @@ -116,6 +125,10 @@ func TestTaskRepositoryCRUDOrderingBatchAndCascade(t *testing.T) { if err != nil || len(grouped["task-repo-one"]) != 2 || len(grouped["task-repo-two"]) != 1 { t.Fatalf("ListTaskRepositoriesByTaskIDs = %v, %v", grouped, err) } + groupedSnapshot := grouped["task-repo-one"][0] + if groupedSnapshot.BranchPolicyID != "policy-hotfix" || groupedSnapshot.BranchPolicyName != "Hotfix" || groupedSnapshot.BranchPolicyBaseBranch != "develop" || groupedSnapshot.BranchPolicyBranchTemplate != "hotfix/{title}" || groupedSnapshot.BranchPolicyPullRequestTarget != "main" { + t.Fatalf("batched task repository snapshot = %+v", groupedSnapshot) + } empty, err := repo.ListTaskRepositoriesByTaskIDs(ctx, nil) if err != nil || len(empty) != 0 { t.Fatalf("ListTaskRepositoriesByTaskIDs(nil) = %v, %v", empty, err) diff --git a/apps/backend/internal/task/repository/sqlite/repository_branch_policy.go b/apps/backend/internal/task/repository/sqlite/repository_branch_policy.go new file mode 100644 index 0000000000..9170805558 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/repository_branch_policy.go @@ -0,0 +1,172 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "strings" + "time" + + "github.com/google/uuid" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" +) + +const repositoryBranchPolicyColumns = `id, repository_id, name, description, base_branch, branch_template, pull_request_target, created_at, updated_at` + +func (r *Repository) CreateRepositoryBranchPolicy(ctx context.Context, policy *models.RepositoryBranchPolicy) error { + if policy.ID == "" { + policy.ID = uuid.New().String() + } + now := time.Now().UTC() + policy.CreatedAt = now + policy.UpdatedAt = now + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + INSERT INTO repository_branch_policies (`+repositoryBranchPolicyColumns+`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `), policy.ID, policy.RepositoryID, policy.Name, policy.Description, policy.BaseBranch, + policy.BranchTemplate, policy.PullRequestTarget, policy.CreatedAt, policy.UpdatedAt) + return err +} + +func (r *Repository) scanRepositoryBranchPolicy(row *sql.Row) (*models.RepositoryBranchPolicy, error) { + policy := &models.RepositoryBranchPolicy{} + err := row.Scan(&policy.ID, &policy.RepositoryID, &policy.Name, &policy.Description, + &policy.BaseBranch, &policy.BranchTemplate, &policy.PullRequestTarget, + &policy.CreatedAt, &policy.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, repoerrors.ErrRepositoryBranchPolicyNotFound + } + if err != nil { + return nil, err + } + return policy, nil +} + +func (r *Repository) GetRepositoryBranchPolicy(ctx context.Context, id string) (*models.RepositoryBranchPolicy, error) { + return r.scanRepositoryBranchPolicy(r.ro.QueryRowContext(ctx, r.ro.Rebind( + `SELECT `+repositoryBranchPolicyColumns+` FROM repository_branch_policies WHERE id = ?`), id)) +} + +func (r *Repository) GetRepositoryBranchPolicyByName(ctx context.Context, repositoryID, name string) (*models.RepositoryBranchPolicy, error) { + policy, err := r.scanRepositoryBranchPolicy(r.ro.QueryRowContext(ctx, r.ro.Rebind( + `SELECT `+repositoryBranchPolicyColumns+` FROM repository_branch_policies WHERE repository_id = ? AND LOWER(name) = ?`), + repositoryID, strings.ToLower(strings.TrimSpace(name)))) + if errors.Is(err, repoerrors.ErrRepositoryBranchPolicyNotFound) { + return nil, nil + } + return policy, err +} + +func (r *Repository) ListRepositoryBranchPolicies(ctx context.Context, repositoryID string) ([]*models.RepositoryBranchPolicy, error) { + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` + SELECT `+repositoryBranchPolicyColumns+` FROM repository_branch_policies + WHERE repository_id = ? ORDER BY LOWER(name), id + `), repositoryID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + policies := make([]*models.RepositoryBranchPolicy, 0) + for rows.Next() { + policy := &models.RepositoryBranchPolicy{} + if err := rows.Scan(&policy.ID, &policy.RepositoryID, &policy.Name, &policy.Description, + &policy.BaseBranch, &policy.BranchTemplate, &policy.PullRequestTarget, + &policy.CreatedAt, &policy.UpdatedAt); err != nil { + return nil, err + } + policies = append(policies, policy) + } + return policies, rows.Err() +} + +func (r *Repository) ListRepositoryBranchPoliciesByWorkspace(ctx context.Context, workspaceID string) ([]*models.RepositoryBranchPolicy, error) { + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` + SELECT `+repositoryBranchPolicyColumnsWithAlias("p")+` FROM repository_branch_policies p + JOIN repositories r ON r.id = p.repository_id + WHERE r.workspace_id = ? ORDER BY p.repository_id, LOWER(p.name), p.id + `), workspaceID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + policies := make([]*models.RepositoryBranchPolicy, 0) + for rows.Next() { + policy := &models.RepositoryBranchPolicy{} + if err := rows.Scan(&policy.ID, &policy.RepositoryID, &policy.Name, &policy.Description, + &policy.BaseBranch, &policy.BranchTemplate, &policy.PullRequestTarget, + &policy.CreatedAt, &policy.UpdatedAt); err != nil { + return nil, err + } + policies = append(policies, policy) + } + return policies, rows.Err() +} + +func repositoryBranchPolicyColumnsWithAlias(alias string) string { + return alias + ".id, " + alias + ".repository_id, " + alias + ".name, " + alias + ".description, " + + alias + ".base_branch, " + alias + ".branch_template, " + alias + ".pull_request_target, " + + alias + ".created_at, " + alias + ".updated_at" +} + +func (r *Repository) UpdateRepositoryBranchPolicy(ctx context.Context, policy *models.RepositoryBranchPolicy) error { + policy.UpdatedAt = time.Now().UTC() + result, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE repository_branch_policies + SET name = ?, description = ?, base_branch = ?, branch_template = ?, pull_request_target = ?, updated_at = ? + WHERE id = ? + `), policy.Name, policy.Description, policy.BaseBranch, policy.BranchTemplate, + policy.PullRequestTarget, policy.UpdatedAt, policy.ID) + if err != nil { + return err + } + count, err := result.RowsAffected() + if err != nil { + return err + } + if count == 0 { + return repoerrors.ErrRepositoryBranchPolicyNotFound + } + return nil +} + +func (r *Repository) DeleteRepositoryBranchPolicy(ctx context.Context, id string) (bool, error) { + result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM repository_branch_policies WHERE id = ?`), id) + if err != nil { + return false, err + } + count, err := result.RowsAffected() + return count > 0, err +} + +func (r *Repository) CreateRepositoryBranchPoliciesIfEmpty(ctx context.Context, repositoryID string, policies []*models.RepositoryBranchPolicy) error { + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + var count int + if err := tx.GetContext(ctx, &count, r.db.Rebind(`SELECT COUNT(*) FROM repository_branch_policies WHERE repository_id = ?`), repositoryID); err != nil { + return err + } + if count > 0 { + return repoerrors.ErrRepositoryBranchPoliciesExist + } + for _, policy := range policies { + if policy.ID == "" { + policy.ID = uuid.New().String() + } + now := time.Now().UTC() + policy.RepositoryID = repositoryID + policy.CreatedAt = now + policy.UpdatedAt = now + if _, err := tx.ExecContext(ctx, r.db.Rebind(` + INSERT INTO repository_branch_policies (`+repositoryBranchPolicyColumns+`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `), policy.ID, policy.RepositoryID, policy.Name, policy.Description, policy.BaseBranch, + policy.BranchTemplate, policy.PullRequestTarget, policy.CreatedAt, policy.UpdatedAt); err != nil { + return err + } + } + return tx.Commit() +} diff --git a/apps/backend/internal/task/repository/sqlite/repository_branch_policy_test.go b/apps/backend/internal/task/repository/sqlite/repository_branch_policy_test.go new file mode 100644 index 0000000000..7f5eb9d385 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/repository_branch_policy_test.go @@ -0,0 +1,148 @@ +package sqlite + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" +) + +func TestRepositoryBranchPolicySchemaExistsWithCaseInsensitiveNameIndex(t *testing.T) { + repo := newRepoForSetTests(t) + ctx := context.Background() + + var tableName string + if err := repo.DB().QueryRowContext(ctx, ` + SELECT name FROM sqlite_master + WHERE type = 'table' AND name = 'repository_branch_policies' + `).Scan(&tableName); err != nil { + t.Fatalf("repository_branch_policies table is missing: %v", err) + } + if tableName != "repository_branch_policies" { + t.Fatalf("table name = %q", tableName) + } + + var indexName string + if err := repo.DB().QueryRowContext(ctx, ` + SELECT name FROM sqlite_master + WHERE type = 'index' AND name = 'uniq_repository_branch_policies_repository_lower_name' + `).Scan(&indexName); err != nil { + t.Fatalf("case-insensitive policy-name index is missing: %v", err) + } + if indexName != "uniq_repository_branch_policies_repository_lower_name" { + t.Fatalf("index name = %q", indexName) + } +} + +func TestRepositoryBranchPolicyCRUDAndCaseInsensitiveUniqueness(t *testing.T) { + repo := newRepoForSetTests(t) + ctx := context.Background() + seedWorkspace(t, repo, "ws-policy") + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-policy", WorkspaceID: "ws-policy", Name: "repo-policy"}); err != nil { + t.Fatalf("create repository: %v", err) + } + + policy := &models.RepositoryBranchPolicy{ + RepositoryID: "repo-policy", + Name: "Feature", + Description: "Fresh features", + BaseBranch: "develop", + BranchTemplate: "feature/{title}-{suffix}", + PullRequestTarget: "develop", + } + if err := repo.CreateRepositoryBranchPolicy(ctx, policy); err != nil { + t.Fatalf("create policy: %v", err) + } + loaded, err := repo.GetRepositoryBranchPolicy(ctx, policy.ID) + if err != nil { + t.Fatalf("get policy: %v", err) + } + if loaded.Name != "Feature" || loaded.BaseBranch != "develop" { + t.Fatalf("loaded policy = %+v", loaded) + } + byName, err := repo.GetRepositoryBranchPolicyByName(ctx, "repo-policy", "FEATURE") + if err != nil || byName == nil || byName.ID != policy.ID { + t.Fatalf("lookup by name = %+v, err=%v", byName, err) + } + + duplicate := *policy + duplicate.ID = "duplicate-policy" + duplicate.Name = "feature" + if err := repo.CreateRepositoryBranchPolicy(ctx, &duplicate); err == nil { + t.Fatal("case-insensitive duplicate policy name was accepted") + } + + policy.Description = "Updated" + if err := repo.UpdateRepositoryBranchPolicy(ctx, policy); err != nil { + t.Fatalf("update policy: %v", err) + } + policies, err := repo.ListRepositoryBranchPolicies(ctx, "repo-policy") + if err != nil || len(policies) != 1 || policies[0].Description != "Updated" { + t.Fatalf("list policies = %+v, err=%v", policies, err) + } + deleted, err := repo.DeleteRepositoryBranchPolicy(ctx, policy.ID) + if err != nil || !deleted { + t.Fatalf("delete policy = %t, err=%v", deleted, err) + } + if _, err := repo.GetRepositoryBranchPolicy(ctx, policy.ID); !errors.Is(err, repoerrors.ErrRepositoryBranchPolicyNotFound) { + t.Fatalf("deleted policy error = %v", err) + } +} + +func TestRepositoryDeletePrunesBranchPolicies(t *testing.T) { + repo := newRepoForSetTests(t) + ctx := context.Background() + seedWorkspace(t, repo, "ws-policy-prune") + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-policy-prune", WorkspaceID: "ws-policy-prune", Name: "repo-policy-prune"}); err != nil { + t.Fatalf("create repository: %v", err) + } + if err := repo.CreateRepositoryBranchPolicy(ctx, &models.RepositoryBranchPolicy{ID: "policy-prune", RepositoryID: "repo-policy-prune", Name: "Feature"}); err != nil { + t.Fatalf("create policy: %v", err) + } + if err := repo.DeleteRepository(ctx, "repo-policy-prune"); err != nil { + t.Fatalf("delete repository: %v", err) + } + policies, err := repo.ListRepositoryBranchPolicies(ctx, "repo-policy-prune") + if err != nil { + t.Fatalf("list policies: %v", err) + } + if len(policies) != 0 { + t.Fatalf("policies after repository deletion = %+v", policies) + } +} + +func TestListRepositoryBranchPoliciesByWorkspaceGroupsOnlyWorkspaceRepositories(t *testing.T) { + repo := newRepoForSetTests(t) + ctx := context.Background() + seedWorkspace(t, repo, "ws-policy-batch") + seedWorkspace(t, repo, "ws-policy-other") + for _, item := range []struct { + id string + workspaceID string + policyID string + name string + }{ + {id: "repo-batch-a", workspaceID: "ws-policy-batch", policyID: "policy-batch-a", name: "Feature"}, + {id: "repo-batch-b", workspaceID: "ws-policy-batch", policyID: "policy-batch-b", name: "Hotfix"}, + {id: "repo-other", workspaceID: "ws-policy-other", policyID: "policy-other", name: "Other"}, + } { + if err := repo.CreateRepository(ctx, &models.Repository{ID: item.id, WorkspaceID: item.workspaceID, Name: item.id}); err != nil { + t.Fatalf("create repository %s: %v", item.id, err) + } + if err := repo.CreateRepositoryBranchPolicy(ctx, &models.RepositoryBranchPolicy{ + ID: item.policyID, RepositoryID: item.id, Name: item.name, + }); err != nil { + t.Fatalf("create policy %s: %v", item.policyID, err) + } + } + + policies, err := repo.ListRepositoryBranchPoliciesByWorkspace(ctx, "ws-policy-batch") + if err != nil { + t.Fatalf("list workspace policies: %v", err) + } + if len(policies) != 2 || policies[0].RepositoryID != "repo-batch-a" || policies[1].RepositoryID != "repo-batch-b" { + t.Fatalf("workspace policies = %+v, want the two ordered workspace repositories", policies) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/repository_entity.go b/apps/backend/internal/task/repository/sqlite/repository_entity.go index d1e7011946..4e45d6cbd1 100644 --- a/apps/backend/internal/task/repository/sqlite/repository_entity.go +++ b/apps/backend/internal/task/repository/sqlite/repository_entity.go @@ -161,6 +161,7 @@ func (r *Repository) UpdateRepositoryWithSecretBindings( func (r *Repository) pruneRepositoryDependents(ctx context.Context, tx *sqlx.Tx, id string) error { statements := []string{ `DELETE FROM repository_secret_bindings WHERE repository_id = ?`, + `DELETE FROM repository_branch_policies WHERE repository_id = ?`, // A membership row pointing at a deleted repository would offer the user // a repository they can no longer select. Repository sets keep existing // with their remaining members. diff --git a/apps/backend/internal/task/repository/sqlite/task_repository.go b/apps/backend/internal/task/repository/sqlite/task_repository.go index f9f09d305a..d27007f9b0 100644 --- a/apps/backend/internal/task/repository/sqlite/task_repository.go +++ b/apps/backend/internal/task/repository/sqlite/task_repository.go @@ -29,9 +29,14 @@ func (r *Repository) CreateTaskRepository(ctx context.Context, taskRepo *models. _, err = r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO task_repositories ( - id, task_id, repository_id, base_branch, checkout_branch, position, metadata, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `), taskRepo.ID, taskRepo.TaskID, taskRepo.RepositoryID, taskRepo.BaseBranch, taskRepo.CheckoutBranch, taskRepo.Position, string(metadataJSON), taskRepo.CreatedAt, taskRepo.UpdatedAt) + id, task_id, repository_id, base_branch, checkout_branch, branch_policy_id, branch_policy_name, + branch_policy_base_branch, branch_policy_branch_template, branch_policy_pull_request_target, + position, metadata, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), taskRepo.ID, taskRepo.TaskID, taskRepo.RepositoryID, taskRepo.BaseBranch, taskRepo.CheckoutBranch, + taskRepo.BranchPolicyID, taskRepo.BranchPolicyName, taskRepo.BranchPolicyBaseBranch, + taskRepo.BranchPolicyBranchTemplate, taskRepo.BranchPolicyPullRequestTarget, + taskRepo.Position, string(metadataJSON), taskRepo.CreatedAt, taskRepo.UpdatedAt) return err } @@ -41,7 +46,9 @@ func (r *Repository) GetTaskRepository(ctx context.Context, id string) (*models. var metadataJSON string err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` - SELECT id, task_id, repository_id, base_branch, checkout_branch, position, metadata, created_at, updated_at + SELECT id, task_id, repository_id, base_branch, checkout_branch, branch_policy_id, branch_policy_name, + branch_policy_base_branch, branch_policy_branch_template, branch_policy_pull_request_target, + position, metadata, created_at, updated_at FROM task_repositories WHERE id = ? `), id).Scan( &taskRepo.ID, @@ -49,6 +56,11 @@ func (r *Repository) GetTaskRepository(ctx context.Context, id string) (*models. &taskRepo.RepositoryID, &taskRepo.BaseBranch, &taskRepo.CheckoutBranch, + &taskRepo.BranchPolicyID, + &taskRepo.BranchPolicyName, + &taskRepo.BranchPolicyBaseBranch, + &taskRepo.BranchPolicyBranchTemplate, + &taskRepo.BranchPolicyPullRequestTarget, &taskRepo.Position, &metadataJSON, &taskRepo.CreatedAt, @@ -71,7 +83,9 @@ func (r *Repository) GetTaskRepository(ctx context.Context, id string) (*models. // ListTaskRepositories returns all repository links for a task func (r *Repository) ListTaskRepositories(ctx context.Context, taskID string) ([]*models.TaskRepository, error) { rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` - SELECT id, task_id, repository_id, base_branch, checkout_branch, position, metadata, created_at, updated_at + SELECT id, task_id, repository_id, base_branch, checkout_branch, branch_policy_id, branch_policy_name, + branch_policy_base_branch, branch_policy_branch_template, branch_policy_pull_request_target, + position, metadata, created_at, updated_at FROM task_repositories WHERE task_id = ? ORDER BY position ASC, created_at ASC @@ -91,6 +105,11 @@ func (r *Repository) ListTaskRepositories(ctx context.Context, taskID string) ([ &taskRepo.RepositoryID, &taskRepo.BaseBranch, &taskRepo.CheckoutBranch, + &taskRepo.BranchPolicyID, + &taskRepo.BranchPolicyName, + &taskRepo.BranchPolicyBaseBranch, + &taskRepo.BranchPolicyBranchTemplate, + &taskRepo.BranchPolicyPullRequestTarget, &taskRepo.Position, &metadataJSON, &taskRepo.CreatedAt, @@ -134,9 +153,14 @@ func (r *Repository) UpdateTaskRepository(ctx context.Context, taskRepo *models. result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE task_repositories SET - task_id = ?, repository_id = ?, base_branch = ?, checkout_branch = ?, position = ?, metadata = ?, updated_at = ? + task_id = ?, repository_id = ?, base_branch = ?, checkout_branch = ?, branch_policy_id = ?, branch_policy_name = ?, + branch_policy_base_branch = ?, branch_policy_branch_template = ?, branch_policy_pull_request_target = ?, + position = ?, metadata = ?, updated_at = ? WHERE id = ? - `), taskRepo.TaskID, taskRepo.RepositoryID, taskRepo.BaseBranch, taskRepo.CheckoutBranch, taskRepo.Position, string(metadataJSON), taskRepo.UpdatedAt, taskRepo.ID) + `), taskRepo.TaskID, taskRepo.RepositoryID, taskRepo.BaseBranch, taskRepo.CheckoutBranch, + taskRepo.BranchPolicyID, taskRepo.BranchPolicyName, taskRepo.BranchPolicyBaseBranch, + taskRepo.BranchPolicyBranchTemplate, taskRepo.BranchPolicyPullRequestTarget, + taskRepo.Position, string(metadataJSON), taskRepo.UpdatedAt, taskRepo.ID) if err != nil { return err } @@ -176,7 +200,9 @@ func (r *Repository) ListTaskRepositoriesByTaskIDs(ctx context.Context, taskIDs } query := fmt.Sprintf(` - SELECT id, task_id, repository_id, base_branch, checkout_branch, position, metadata, created_at, updated_at + SELECT id, task_id, repository_id, base_branch, checkout_branch, branch_policy_id, branch_policy_name, + branch_policy_base_branch, branch_policy_branch_template, branch_policy_pull_request_target, + position, metadata, created_at, updated_at FROM task_repositories WHERE task_id IN (%s) ORDER BY position ASC, created_at ASC @@ -197,6 +223,11 @@ func (r *Repository) ListTaskRepositoriesByTaskIDs(ctx context.Context, taskIDs &taskRepo.RepositoryID, &taskRepo.BaseBranch, &taskRepo.CheckoutBranch, + &taskRepo.BranchPolicyID, + &taskRepo.BranchPolicyName, + &taskRepo.BranchPolicyBaseBranch, + &taskRepo.BranchPolicyBranchTemplate, + &taskRepo.BranchPolicyPullRequestTarget, &taskRepo.Position, &metadataJSON, &taskRepo.CreatedAt, diff --git a/apps/backend/internal/task/repository/sqlite/task_repository_branch_policy_migration_test.go b/apps/backend/internal/task/repository/sqlite/task_repository_branch_policy_migration_test.go new file mode 100644 index 0000000000..7c8e4902f9 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/task_repository_branch_policy_migration_test.go @@ -0,0 +1,66 @@ +package sqlite + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/task/models" +) + +func TestTaskRepositoryBranchPolicyMigrationPreservesLegacyRows(t *testing.T) { + repo := newRepoForEntityTests(t) + ctx := context.Background() + seedWorkspace(t, repo, "workspace-task-repository-policy-migration") + if err := repo.CreateWorkflow(ctx, &models.Workflow{ + ID: "workflow-task-repository-policy-migration", WorkspaceID: "workspace-task-repository-policy-migration", Name: "Workflow", + }); err != nil { + t.Fatalf("create workflow: %v", err) + } + if err := repo.CreateTask(ctx, &models.Task{ + ID: "task-task-repository-policy-migration", WorkspaceID: "workspace-task-repository-policy-migration", + WorkflowID: "workflow-task-repository-policy-migration", Title: "Legacy task", + }); err != nil { + t.Fatalf("create task: %v", err) + } + if err := repo.CreateRepository(ctx, &models.Repository{ + ID: "repo-task-repository-policy-migration", WorkspaceID: "workspace-task-repository-policy-migration", Name: "Legacy repository", + }); err != nil { + t.Fatalf("create repository: %v", err) + } + if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ + ID: "task-repository-policy-migration", TaskID: "task-task-repository-policy-migration", + RepositoryID: "repo-task-repository-policy-migration", BaseBranch: "main", Position: 0, + }); err != nil { + t.Fatalf("create legacy task repository: %v", err) + } + + for _, column := range []string{ + "branch_policy_pull_request_target", + "branch_policy_branch_template", + "branch_policy_base_branch", + "branch_policy_name", + "branch_policy_id", + } { + if _, err := repo.db.Exec(`ALTER TABLE task_repositories DROP COLUMN ` + column); err != nil { + t.Fatalf("drop legacy column %s: %v", column, err) + } + } + + if err := repo.runMigrations(); err != nil { + t.Fatalf("migrate legacy task repository schema: %v", err) + } + if err := repo.runMigrations(); err != nil { + t.Fatalf("replay legacy task repository migration: %v", err) + } + + got, err := repo.GetTaskRepository(ctx, "task-repository-policy-migration") + if err != nil { + t.Fatalf("get migrated task repository: %v", err) + } + if got.BaseBranch != "main" { + t.Fatalf("migrated base branch = %q, want main", got.BaseBranch) + } + if got.BranchPolicyID != "" || got.BranchPolicyName != "" || got.BranchPolicyBaseBranch != "" || got.BranchPolicyBranchTemplate != "" || got.BranchPolicyPullRequestTarget != "" { + t.Fatalf("migrated policy snapshot = %+v, want empty fields", got) + } +} diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 03d6d7bb5d..f7ec8f4e63 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -281,6 +281,7 @@ type Repos struct { GitSnapshots repository.GitSnapshotRepository RepoEntities repository.RepositoryEntityRepository RepositorySets repository.RepositorySetRepository + BranchPolicies repository.RepositoryBranchPolicyRepository RepositoryCleanup repository.RepositoryCleanupRepository Executors repository.ExecutorRepository Environments repository.EnvironmentRepository @@ -307,6 +308,7 @@ type Service struct { gitSnapshots repository.GitSnapshotRepository repoEntities repository.RepositoryEntityRepository repositorySets repository.RepositorySetRepository + branchPolicies repository.RepositoryBranchPolicyRepository repositoryCleanup repository.RepositoryCleanupRepository executors repository.ExecutorRepository environments repository.EnvironmentRepository @@ -452,6 +454,7 @@ func NewService(repos Repos, eventBus bus.EventBus, log *logger.Logger, discover gitSnapshots: repos.GitSnapshots, repoEntities: repos.RepoEntities, repositorySets: repos.RepositorySets, + branchPolicies: repos.BranchPolicies, repositoryCleanup: repos.RepositoryCleanup, executors: repos.Executors, environments: repos.Environments, diff --git a/apps/backend/internal/task/service/service_events.go b/apps/backend/internal/task/service/service_events.go index 42e095a963..37af748915 100644 --- a/apps/backend/internal/task/service/service_events.go +++ b/apps/backend/internal/task/service/service_events.go @@ -675,14 +675,37 @@ func taskRepositoriesForEvent(ctx context.Context, s *Service, task *models.Task func serializeTaskRepositories(repos []*models.TaskRepository) []map[string]interface{} { out := make([]map[string]interface{}, 0, len(repos)) for _, r := range repos { - out = append(out, map[string]interface{}{ - "id": r.ID, - "task_id": r.TaskID, - "repository_id": r.RepositoryID, - "base_branch": r.BaseBranch, - "checkout_branch": r.CheckoutBranch, - "position": r.Position, - }) + serialized := map[string]interface{}{ + "id": r.ID, + "task_id": r.TaskID, + "repository_id": r.RepositoryID, + "base_branch": r.BaseBranch, + "position": r.Position, + "created_at": r.CreatedAt.Format(time.RFC3339Nano), + "updated_at": r.UpdatedAt.Format(time.RFC3339Nano), + } + if r.CheckoutBranch != "" { + serialized["checkout_branch"] = r.CheckoutBranch + } + if r.BranchPolicyID != "" { + serialized["branch_policy_id"] = r.BranchPolicyID + } + if r.BranchPolicyName != "" { + serialized["branch_policy_name"] = r.BranchPolicyName + } + if r.BranchPolicyBaseBranch != "" { + serialized["branch_policy_base_branch"] = r.BranchPolicyBaseBranch + } + if r.BranchPolicyBranchTemplate != "" { + serialized["branch_policy_branch_template"] = r.BranchPolicyBranchTemplate + } + if r.BranchPolicyPullRequestTarget != "" { + serialized["branch_policy_pull_request_target"] = r.BranchPolicyPullRequestTarget + } + if len(r.Metadata) > 0 { + serialized["metadata"] = r.Metadata + } + out = append(out, serialized) } return out } diff --git a/apps/backend/internal/task/service/service_events_branch_policy_test.go b/apps/backend/internal/task/service/service_events_branch_policy_test.go new file mode 100644 index 0000000000..73bf9e169e --- /dev/null +++ b/apps/backend/internal/task/service/service_events_branch_policy_test.go @@ -0,0 +1,69 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/task/models" +) + +func TestTaskEventsSerializeBranchPolicySnapshot(t *testing.T) { + createdAt := time.Date(2026, 8, 24, 10, 0, 0, 0, time.UTC) + updatedAt := createdAt.Add(time.Minute) + + for _, eventType := range []string{events.TaskCreated, events.TaskUpdated} { + t.Run(eventType, func(t *testing.T) { + svc, eventBus, _ := createTestService(t) + task := &models.Task{ + ID: "task-policy-event", WorkspaceID: "ws-policy-event", WorkflowID: "workflow-policy-event", + Title: "Policy event", CreatedAt: createdAt, UpdatedAt: updatedAt, + Repositories: []*models.TaskRepository{{ + ID: "task-repo-policy-event", TaskID: "task-policy-event", RepositoryID: "repo-policy-event", + BaseBranch: "main", CheckoutBranch: "feature/policy-event", Position: 0, + BranchPolicyID: "policy-event", BranchPolicyName: "Feature branches", + BranchPolicyBaseBranch: "main", BranchPolicyBranchTemplate: "feature/{title}-{suffix}", + BranchPolicyPullRequestTarget: "develop", Metadata: map[string]interface{}{"source": "test"}, + CreatedAt: createdAt, UpdatedAt: updatedAt, + }}, + } + + svc.publishTaskEvent(context.Background(), eventType, task, nil) + + published := eventBus.GetPublishedEvents() + if len(published) != 1 || published[0].Type != eventType { + t.Fatalf("published events = %#v, want one %s event", published, eventType) + } + data, ok := published[0].Data.(map[string]interface{}) + if !ok { + t.Fatalf("event data type = %T, want map[string]interface{}", published[0].Data) + } + repositories, ok := data["repositories"].([]map[string]interface{}) + if !ok || len(repositories) != 1 { + t.Fatalf("repositories = %#v, want one serialized repository", data["repositories"]) + } + repository := repositories[0] + for key, want := range map[string]string{ + "branch_policy_id": "policy-event", + "branch_policy_name": "Feature branches", + "branch_policy_base_branch": "main", + "branch_policy_branch_template": "feature/{title}-{suffix}", + "branch_policy_pull_request_target": "develop", + } { + if got := repository[key]; got != want { + t.Errorf("repositories[0][%q] = %#v, want %q", key, got, want) + } + } + if got := repository["metadata"]; got == nil { + t.Error("repositories[0][metadata] is missing") + } + if got := repository["created_at"]; got != createdAt.Format(time.RFC3339) { + t.Errorf("repositories[0][created_at] = %#v, want %q", got, createdAt.Format(time.RFC3339)) + } + if got := repository["updated_at"]; got != updatedAt.Format(time.RFC3339) { + t.Errorf("repositories[0][updated_at] = %#v, want %q", got, updatedAt.Format(time.RFC3339)) + } + }) + } +} diff --git a/apps/backend/internal/task/service/service_repository_branch_policies.go b/apps/backend/internal/task/service/service_repository_branch_policies.go new file mode 100644 index 0000000000..5521b54b4d --- /dev/null +++ b/apps/backend/internal/task/service/service_repository_branch_policies.go @@ -0,0 +1,349 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" + "github.com/kandev/kandev/internal/worktree" +) + +const ( + repositoryBranchPolicyNameMaxLength = 100 + repositoryBranchPolicyDescriptionMaxLength = 500 + defaultGitflowDevelopmentBranch = "develop" + BranchPolicyStaleErrorCode = "branch_policy_stale" +) + +var ( + ErrInvalidRepositoryBranchPolicy = errors.New("invalid repository branch policy") + ErrRepositoryBranchPolicyNameConflict = errors.New("repository branch policy name already used") + ErrRepositoryBranchPolicyAlreadySeeded = errors.New("repository branch policies already seeded") + ErrRepositoryBranchPolicyReadOnly = errors.New("this workspace is managed by Improve Kandev and is read-only") + ErrRepositoryBranchPolicyStoreMissing = errors.New("repository branch policy store is unavailable") + ErrRepositoryBranchPolicyStale = errors.New("repository branch policy is no longer available") +) + +type CreateRepositoryBranchPolicyRequest struct { + RepositoryID string `json:"repository_id"` + Name string `json:"name"` + Description string `json:"description"` + BaseBranch string `json:"base_branch"` + BranchTemplate string `json:"branch_template"` + PullRequestTarget string `json:"pull_request_target"` +} + +type UpdateRepositoryBranchPolicyRequest struct { + Name *string `json:"name"` + Description *string `json:"description"` + BaseBranch *string `json:"base_branch"` + BranchTemplate *string `json:"branch_template"` + PullRequestTarget *string `json:"pull_request_target"` +} + +type CreateGitflowRepositoryBranchPoliciesRequest struct { + RepositoryID string `json:"repository_id"` + ProductionBranch string `json:"production_branch"` + DevelopmentBranch string `json:"development_branch"` +} + +func (s *Service) CreateRepositoryBranchPolicy(ctx context.Context, req *CreateRepositoryBranchPolicyRequest) (*models.RepositoryBranchPolicy, error) { + if err := s.ensureBranchPolicyStore(); err != nil { + return nil, err + } + repository, err := s.authorizeWritableBranchPolicyRepository(ctx, req.RepositoryID) + if err != nil { + return nil, err + } + policy, err := normalizeRepositoryBranchPolicy(&models.RepositoryBranchPolicy{ + RepositoryID: req.RepositoryID, Name: req.Name, Description: req.Description, + BaseBranch: req.BaseBranch, BranchTemplate: req.BranchTemplate, + PullRequestTarget: req.PullRequestTarget, + }) + if err != nil { + return nil, err + } + if err := s.assertBranchPolicyNameFree(ctx, repository.ID, policy.Name, ""); err != nil { + return nil, err + } + if err := s.branchPolicies.CreateRepositoryBranchPolicy(ctx, policy); err != nil { + return nil, err + } + s.publishRepositoryBranchPolicyEvent(ctx, events.RepositoryBranchPolicyCreated, repository.WorkspaceID, policy) + return policy, nil +} + +func (s *Service) GetRepositoryBranchPolicy(ctx context.Context, id string) (*models.RepositoryBranchPolicy, error) { + if err := s.ensureBranchPolicyStore(); err != nil { + return nil, repoerrors.ErrRepositoryBranchPolicyNotFound + } + policy, err := s.branchPolicies.GetRepositoryBranchPolicy(ctx, id) + if err != nil { + return nil, err + } + if _, err := s.authorizeBranchPolicyRepository(ctx, policy.RepositoryID); err != nil { + return nil, repoerrors.ErrRepositoryBranchPolicyNotFound + } + return policy, nil +} + +func (s *Service) ListRepositoryBranchPolicies(ctx context.Context, repositoryID string) ([]*models.RepositoryBranchPolicy, error) { + if err := s.ensureBranchPolicyStore(); err != nil { + return nil, err + } + if _, err := s.authorizeBranchPolicyRepository(ctx, repositoryID); err != nil { + return nil, err + } + return s.branchPolicies.ListRepositoryBranchPolicies(ctx, repositoryID) +} + +func (s *Service) ListRepositoryBranchPoliciesForWorkspace(ctx context.Context, workspaceID string) ([]*models.RepositoryBranchPolicy, error) { + if err := s.ensureBranchPolicyStore(); err != nil { + return nil, err + } + if err := s.authorizeWorkspaceID(ctx, workspaceID); err != nil { + return nil, err + } + return s.branchPolicies.ListRepositoryBranchPoliciesByWorkspace(ctx, workspaceID) +} + +func (s *Service) UpdateRepositoryBranchPolicy(ctx context.Context, id string, req *UpdateRepositoryBranchPolicyRequest) (*models.RepositoryBranchPolicy, error) { + if err := s.ensureBranchPolicyStore(); err != nil { + return nil, err + } + policy, err := s.GetRepositoryBranchPolicy(ctx, id) + if err != nil { + return nil, err + } + if req.Name != nil { + policy.Name = *req.Name + } + if req.Description != nil { + policy.Description = *req.Description + } + if req.BaseBranch != nil { + policy.BaseBranch = *req.BaseBranch + } + if req.BranchTemplate != nil { + policy.BranchTemplate = *req.BranchTemplate + } + if req.PullRequestTarget != nil { + policy.PullRequestTarget = *req.PullRequestTarget + } + repository, err := s.authorizeWritableBranchPolicyRepository(ctx, policy.RepositoryID) + if err != nil { + return nil, err + } + normalized, err := normalizeRepositoryBranchPolicy(policy) + if err != nil { + return nil, err + } + if err := s.assertBranchPolicyNameFree(ctx, policy.RepositoryID, normalized.Name, policy.ID); err != nil { + return nil, err + } + if err := s.branchPolicies.UpdateRepositoryBranchPolicy(ctx, normalized); err != nil { + return nil, err + } + s.publishRepositoryBranchPolicyEvent(ctx, events.RepositoryBranchPolicyUpdated, repository.WorkspaceID, normalized) + return normalized, nil +} + +func (s *Service) DeleteRepositoryBranchPolicy(ctx context.Context, id string) error { + if err := s.ensureBranchPolicyStore(); err != nil { + return err + } + policy, err := s.GetRepositoryBranchPolicy(ctx, id) + if err != nil { + return err + } + repository, err := s.authorizeWritableBranchPolicyRepository(ctx, policy.RepositoryID) + if err != nil { + return err + } + deleted, err := s.branchPolicies.DeleteRepositoryBranchPolicy(ctx, id) + if err != nil { + return err + } + if !deleted { + return repoerrors.ErrRepositoryBranchPolicyNotFound + } + s.publishRepositoryBranchPolicyEvent(ctx, events.RepositoryBranchPolicyDeleted, repository.WorkspaceID, policy) + return nil +} + +func (s *Service) CreateGitflowRepositoryBranchPolicies(ctx context.Context, req *CreateGitflowRepositoryBranchPoliciesRequest) ([]*models.RepositoryBranchPolicy, error) { + if err := s.ensureBranchPolicyStore(); err != nil { + return nil, err + } + repository, err := s.authorizeWritableBranchPolicyRepository(ctx, req.RepositoryID) + if err != nil { + return nil, err + } + production := strings.TrimSpace(req.ProductionBranch) + if production == "" { + production = strings.TrimSpace(repository.DefaultBranch) + } + development := strings.TrimSpace(req.DevelopmentBranch) + if development == "" { + development = defaultGitflowDevelopmentBranch + } + if err := validatePolicyBranchRef(production, "production branch"); err != nil { + return nil, err + } + if err := validatePolicyBranchRef(development, "development branch"); err != nil { + return nil, err + } + if production == development { + return nil, fmt.Errorf("%w: production and development branches must differ", ErrInvalidRepositoryBranchPolicy) + } + if err := s.validateGitflowBranches(ctx, repository.ID, production, development); err != nil { + return nil, err + } + policies := gitflowPolicies(req.RepositoryID, production, development) + if err := s.branchPolicies.CreateRepositoryBranchPoliciesIfEmpty(ctx, repository.ID, policies); err != nil { + if errors.Is(err, repoerrors.ErrRepositoryBranchPoliciesExist) { + return nil, fmt.Errorf("%w: repository already has branch policies", ErrRepositoryBranchPolicyAlreadySeeded) + } + return nil, err + } + for _, policy := range policies { + s.publishRepositoryBranchPolicyEvent(ctx, events.RepositoryBranchPolicyCreated, repository.WorkspaceID, policy) + } + return policies, nil +} + +func (s *Service) validateGitflowBranches(ctx context.Context, repositoryID, production, development string) error { + branches, err := s.ListBranches(ctx, repositoryID, "") + if err != nil { + return fmt.Errorf("%w: cannot list branches: %v", ErrInvalidRepositoryBranchPolicy, err) + } + available := make(map[string]struct{}, len(branches)*2) + for _, branch := range branches { + available[branch.Name] = struct{}{} + if branch.Type == "remote" && branch.Remote != "" { + available[branch.Remote+"/"+branch.Name] = struct{}{} + } + } + for label, branch := range map[string]string{ + "production branch": production, + "development branch": development, + } { + if _, ok := available[branch]; !ok { + return fmt.Errorf("%w: %s %q does not exist", ErrInvalidRepositoryBranchPolicy, label, branch) + } + } + return nil +} + +func (s *Service) ensureBranchPolicyStore() error { + if s.branchPolicies == nil { + return ErrRepositoryBranchPolicyStoreMissing + } + return nil +} + +func (s *Service) authorizeBranchPolicyRepository(ctx context.Context, repositoryID string) (*models.Repository, error) { + repository, err := s.repoEntities.GetRepository(ctx, repositoryID) + if err != nil { + return nil, err + } + if repository == nil { + return nil, repoerrors.ErrRepositoryNotFound + } + if err := s.authorizeWorkspaceID(ctx, repository.WorkspaceID); err != nil { + return nil, repoerrors.ErrRepositoryNotFound + } + return repository, nil +} + +func (s *Service) authorizeWritableBranchPolicyRepository(ctx context.Context, repositoryID string) (*models.Repository, error) { + repository, err := s.authorizeBranchPolicyRepository(ctx, repositoryID) + if err != nil { + return nil, err + } + workspace, err := s.workspaces.GetWorkspace(ctx, repository.WorkspaceID) + if err != nil { + return nil, err + } + if workspace != nil && workspace.IsImproveKandev() { + return nil, ErrRepositoryBranchPolicyReadOnly + } + return repository, nil +} + +func (s *Service) assertBranchPolicyNameFree(ctx context.Context, repositoryID, name, exceptID string) error { + existing, err := s.branchPolicies.GetRepositoryBranchPolicyByName(ctx, repositoryID, name) + if err != nil { + return err + } + if existing != nil && existing.ID != exceptID { + return fmt.Errorf("%w: %q", ErrRepositoryBranchPolicyNameConflict, existing.Name) + } + return nil +} + +func normalizeRepositoryBranchPolicy(policy *models.RepositoryBranchPolicy) (*models.RepositoryBranchPolicy, error) { + policy.Name = strings.TrimSpace(policy.Name) + policy.Description = strings.TrimSpace(policy.Description) + policy.BaseBranch = strings.TrimSpace(policy.BaseBranch) + policy.BranchTemplate = strings.TrimSpace(policy.BranchTemplate) + policy.PullRequestTarget = strings.TrimSpace(policy.PullRequestTarget) + if policy.PullRequestTarget == "" { + policy.PullRequestTarget = policy.BaseBranch + } + if policy.Name == "" || len([]rune(policy.Name)) > repositoryBranchPolicyNameMaxLength { + return nil, fmt.Errorf("%w: name must be between 1 and %d characters", ErrInvalidRepositoryBranchPolicy, repositoryBranchPolicyNameMaxLength) + } + if len([]rune(policy.Description)) > repositoryBranchPolicyDescriptionMaxLength { + return nil, fmt.Errorf("%w: description must be at most %d characters", ErrInvalidRepositoryBranchPolicy, repositoryBranchPolicyDescriptionMaxLength) + } + if err := validatePolicyBranchRef(policy.BaseBranch, "base branch"); err != nil { + return nil, err + } + if err := worktree.ValidateBranchNameTemplate(policy.BranchTemplate); err != nil { + return nil, fmt.Errorf("%w: branch template: %v", ErrInvalidRepositoryBranchPolicy, err) + } + if err := validatePolicyBranchRef(policy.PullRequestTarget, "pull request target"); err != nil { + return nil, err + } + return policy, nil +} + +func validatePolicyBranchRef(value, label string) error { + if _, err := sanitizeGitRef(strings.TrimSpace(value), label); err != nil { + return fmt.Errorf("%w: %v", ErrInvalidRepositoryBranchPolicy, err) + } + return nil +} + +func gitflowPolicies(repositoryID, production, development string) []*models.RepositoryBranchPolicy { + return []*models.RepositoryBranchPolicy{ + {RepositoryID: repositoryID, Name: "Feature", Description: "Feature branches", BaseBranch: development, BranchTemplate: "feature/{title}-{suffix}", PullRequestTarget: development}, + {RepositoryID: repositoryID, Name: "Bugfix", Description: "Bug-fix branches", BaseBranch: development, BranchTemplate: "bugfix/{title}-{suffix}", PullRequestTarget: development}, + {RepositoryID: repositoryID, Name: "Hotfix", Description: "Production hotfix branches", BaseBranch: production, BranchTemplate: "hotfix/{title}-{suffix}", PullRequestTarget: production}, + {RepositoryID: repositoryID, Name: "Release", Description: "Release branches", BaseBranch: development, BranchTemplate: "release/{title}-{suffix}", PullRequestTarget: production}, + } +} + +func (s *Service) publishRepositoryBranchPolicyEvent(ctx context.Context, eventType, workspaceID string, policy *models.RepositoryBranchPolicy) { + if s.eventBus == nil || policy == nil { + return + } + event := map[string]interface{}{ + "id": policy.ID, "repository_id": policy.RepositoryID, "workspace_id": workspaceID, "name": policy.Name, + "description": policy.Description, "base_branch": policy.BaseBranch, + "branch_template": policy.BranchTemplate, "pull_request_target": policy.PullRequestTarget, + "created_at": policy.CreatedAt.Format(time.RFC3339), "updated_at": policy.UpdatedAt.Format(time.RFC3339), + } + if err := s.eventBus.Publish(ctx, eventType, bus.NewEvent(eventType, "task-service", event)); err != nil { + s.logger.Error("failed to publish repository branch policy event", zap.String("event_type", eventType), zap.String("repository_branch_policy_id", policy.ID), zap.Error(err)) + } +} diff --git a/apps/backend/internal/task/service/service_repository_branch_policies_test.go b/apps/backend/internal/task/service/service_repository_branch_policies_test.go new file mode 100644 index 0000000000..d6328bc66e --- /dev/null +++ b/apps/backend/internal/task/service/service_repository_branch_policies_test.go @@ -0,0 +1,286 @@ +package service + +import ( + "context" + "errors" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" +) + +func seedBranchPolicyRepository(t *testing.T, repo interface { + CreateWorkspace(context.Context, *models.Workspace) error + CreateRepository(context.Context, *models.Repository) error +}) { + t.Helper() + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-policy-service", Name: "Policy workspace"}); err != nil { + t.Fatalf("create workspace: %v", err) + } + if err := repo.CreateRepository(ctx, &models.Repository{ + ID: "repo-policy-service", WorkspaceID: "ws-policy-service", Name: "Policy repo", DefaultBranch: "main", + }); err != nil { + t.Fatalf("create repository: %v", err) + } +} + +func TestRepositoryBranchPolicyServiceNormalizesAndRejectsInvalidUpdates(t *testing.T) { + svc, _, repo := createTestService(t) + seedBranchPolicyRepository(t, repo) + ctx := context.Background() + + policy, err := svc.CreateRepositoryBranchPolicy(ctx, &CreateRepositoryBranchPolicyRequest{ + RepositoryID: "repo-policy-service", Name: " Feature ", Description: " branch flow ", + BaseBranch: " develop ", BranchTemplate: " feature/{title}-{suffix} ", PullRequestTarget: " develop ", + }) + if err != nil { + t.Fatalf("CreateRepositoryBranchPolicy: %v", err) + } + if policy.Name != "Feature" || policy.Description != "branch flow" || policy.BaseBranch != "develop" { + t.Fatalf("normalized policy = %+v", policy) + } + + _, err = svc.CreateRepositoryBranchPolicy(ctx, &CreateRepositoryBranchPolicyRequest{ + RepositoryID: "repo-policy-service", Name: "feature", BaseBranch: "main", + BranchTemplate: "feature/{title}-{suffix}", PullRequestTarget: "main", + }) + if !errors.Is(err, ErrRepositoryBranchPolicyNameConflict) { + t.Fatalf("duplicate name error = %v", err) + } + + tooLong := make([]rune, repositoryBranchPolicyDescriptionMaxLength+1) + for i := range tooLong { + tooLong[i] = 'x' + } + _, err = svc.UpdateRepositoryBranchPolicy(ctx, policy.ID, &UpdateRepositoryBranchPolicyRequest{ + Description: stringPointer(string(tooLong)), + }) + if !errors.Is(err, ErrInvalidRepositoryBranchPolicy) { + t.Fatalf("long description error = %v", err) + } + + if _, err := svc.GetRepositoryBranchPolicy(ctx, "missing"); !errors.Is(err, repoerrors.ErrRepositoryBranchPolicyNotFound) { + t.Fatalf("missing policy error = %v", err) + } +} + +func TestNormalizeRepositoryBranchPolicyDefaultsPullRequestTarget(t *testing.T) { + policy, err := normalizeRepositoryBranchPolicy(&models.RepositoryBranchPolicy{ + Name: "Feature", + BaseBranch: "develop", + BranchTemplate: "feature/{title}-{suffix}", + }) + if err != nil { + t.Fatalf("normalizeRepositoryBranchPolicy: %v", err) + } + if policy.PullRequestTarget != "develop" { + t.Fatalf("pull request target = %q, want base branch", policy.PullRequestTarget) + } +} + +func TestRepositoryBranchPolicyServiceGitflowStarterIsAtomicAndOneTime(t *testing.T) { + isolateGitEnvForTest(t) + svc, eventBus, repo := createTestService(t) + seedBranchPolicyRepository(t, repo) + repository, err := repo.GetRepository(context.Background(), "repo-policy-service") + if err != nil { + t.Fatalf("get repository: %v", err) + } + repositoryPath := filepath.Join(t.TempDir(), "policy-repo") + initRealGitRepo(t, repositoryPath) + cmd := exec.Command("git", "branch", "develop") + cmd.Dir = repositoryPath + cmd.Env = isolatedGitEnv() + if output, branchErr := cmd.CombinedOutput(); branchErr != nil { + t.Fatalf("create develop branch: %v (%s)", branchErr, output) + } + repository.LocalPath = repositoryPath + repository.SourceType = sourceTypeLocal + if err := repo.UpdateRepository(context.Background(), repository); err != nil { + t.Fatalf("update repository path: %v", err) + } + ctx := context.Background() + + policies, err := svc.CreateGitflowRepositoryBranchPolicies(ctx, &CreateGitflowRepositoryBranchPoliciesRequest{ + RepositoryID: "repo-policy-service", ProductionBranch: "main", DevelopmentBranch: "develop", + }) + if err != nil { + t.Fatalf("CreateGitflowRepositoryBranchPolicies: %v", err) + } + if len(policies) != 4 { + t.Fatalf("seeded %d policies, want 4", len(policies)) + } + if policies[0].BaseBranch != "develop" || policies[2].BaseBranch != "main" || policies[3].PullRequestTarget != "main" { + t.Fatalf("gitflow policies = %+v", policies) + } + if len(eventBus.GetPublishedEvents()) != 4 { + t.Fatalf("published events = %d, want 4", len(eventBus.GetPublishedEvents())) + } + data, ok := eventBus.GetPublishedEvents()[0].Data.(map[string]interface{}) + if !ok || data["workspace_id"] != "ws-policy-service" { + t.Fatalf("first policy event data = %#v, want workspace_id", eventBus.GetPublishedEvents()[0].Data) + } + + _, err = svc.CreateGitflowRepositoryBranchPolicies(ctx, &CreateGitflowRepositoryBranchPoliciesRequest{ + RepositoryID: "repo-policy-service", ProductionBranch: "main", DevelopmentBranch: "develop", + }) + if !errors.Is(err, ErrRepositoryBranchPolicyAlreadySeeded) { + t.Fatalf("second starter error = %v", err) + } + stored, err := repo.ListRepositoryBranchPolicies(ctx, "repo-policy-service") + if err != nil || len(stored) != 4 { + t.Fatalf("stored policies after rejected starter = %d, err=%v", len(stored), err) + } +} + +func TestRepositoryBranchPolicyServiceRejectsImproveWorkspaceMutations(t *testing.T) { + svc, eventBus, repo := createTestService(t) + seedBranchPolicyRepository(t, repo) + if err := repo.CreateRepositoryBranchPolicy(context.Background(), &models.RepositoryBranchPolicy{ + ID: "policy-improve", RepositoryID: "repo-policy-service", Name: "Existing", + BaseBranch: "main", BranchTemplate: "feature/{title}-{suffix}", PullRequestTarget: "main", + }); err != nil { + t.Fatalf("seed branch policy: %v", err) + } + workspace, err := repo.GetWorkspace(context.Background(), "ws-policy-service") + if err != nil { + t.Fatalf("get workspace: %v", err) + } + workspace.Name = models.WorkspaceNameImproveKandev + if err := repo.UpdateWorkspace(context.Background(), workspace); err != nil { + t.Fatalf("update workspace: %v", err) + } + + _, err = svc.CreateRepositoryBranchPolicy(context.Background(), &CreateRepositoryBranchPolicyRequest{ + RepositoryID: "repo-policy-service", Name: "Feature", BaseBranch: "main", + BranchTemplate: "feature/{title}-{suffix}", PullRequestTarget: "main", + }) + if !errors.Is(err, ErrRepositoryBranchPolicyReadOnly) { + t.Fatalf("create read-only workspace error = %v", err) + } + + _, err = svc.UpdateRepositoryBranchPolicy(context.Background(), "policy-improve", &UpdateRepositoryBranchPolicyRequest{ + Name: stringPointer("Renamed"), + }) + if !errors.Is(err, ErrRepositoryBranchPolicyReadOnly) { + t.Fatalf("update read-only workspace error = %v", err) + } + + _, err = svc.CreateGitflowRepositoryBranchPolicies(context.Background(), &CreateGitflowRepositoryBranchPoliciesRequest{ + RepositoryID: "repo-policy-service", ProductionBranch: "main", DevelopmentBranch: "develop", + }) + if !errors.Is(err, ErrRepositoryBranchPolicyReadOnly) { + t.Fatalf("gitflow read-only workspace error = %v", err) + } + + err = svc.DeleteRepositoryBranchPolicy(context.Background(), "policy-improve") + if !errors.Is(err, ErrRepositoryBranchPolicyReadOnly) { + t.Fatalf("delete read-only workspace error = %v", err) + } + if len(eventBus.GetPublishedEvents()) != 0 { + t.Fatalf("read-only mutation published events = %d", len(eventBus.GetPublishedEvents())) + } +} + +func TestCreateTaskRejectsRemoteContributionPolicyBaseMismatch(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + workflowID := seedWorkspaceAndWorkflowForCreate(t, ctx, repo, "ws-policy-remote") + if err := repo.CreateRepository(ctx, &models.Repository{ + ID: "repo-policy-remote", WorkspaceID: "ws-policy-remote", Name: "remote", DefaultBranch: "main", + }); err != nil { + t.Fatalf("create repository: %v", err) + } + policy, err := svc.CreateRepositoryBranchPolicy(ctx, &CreateRepositoryBranchPolicyRequest{ + RepositoryID: "repo-policy-remote", Name: "Feature", BaseBranch: "main", + BranchTemplate: "feature/{title}-{suffix}", PullRequestTarget: "main", + }) + if err != nil { + t.Fatalf("create policy: %v", err) + } + + _, err = svc.CreateTask(ctx, &CreateTaskRequest{ + WorkspaceID: "ws-policy-remote", WorkflowID: workflowID, Title: "Remote contribution", + Repositories: []TaskRepositoryInput{{ + RepositoryID: "repo-policy-remote", BranchPolicyID: policy.ID, BaseBranch: "release", + CheckoutBranch: "feature/remote", RemoteContribution: &models.RemoteContribution{ + Version: 1, Provider: "github", Kind: "pull_request", CanonicalURL: "https://github.com/acme/remote/pull/1", + Number: 1, State: "open", BaseBranch: "release", HeadBranch: "feature/remote", + HeadSHA: "0123456789abcdef0123456789abcdef01234567", + SourceRepository: models.RemoteContributionRepository{ + Host: "github.com", Path: "acme/remote", RemoteURL: "https://github.com/acme/remote.git", + }, + }, + }}, + }) + if err == nil || !strings.Contains(err.Error(), "does not match branch policy base branch") { + t.Fatalf("remote contribution mismatch error = %v", err) + } + _, total, err := repo.ListTasksByWorkspace(ctx, "ws-policy-remote", "", "", "", 1, 50, "", false, false, false, false) + if err != nil { + t.Fatalf("list tasks after rejected create: %v", err) + } + if total != 0 { + t.Fatalf("rejected remote contribution mismatch persisted %d task(s)", total) + } +} + +func TestReplaceTaskRepositoriesPreservesFreshBranchWithPolicySnapshot(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + workflowID := seedWorkspaceAndWorkflowForCreate(t, ctx, repo, "ws-policy-fresh") + if err := repo.CreateRepository(ctx, &models.Repository{ + ID: "repo-policy-fresh", WorkspaceID: "ws-policy-fresh", Name: "fresh", DefaultBranch: "main", + }); err != nil { + t.Fatalf("create repository: %v", err) + } + policy, err := svc.CreateRepositoryBranchPolicy(ctx, &CreateRepositoryBranchPolicyRequest{ + RepositoryID: "repo-policy-fresh", Name: "Feature", BaseBranch: "main", + BranchTemplate: "feature/{title}-{suffix}", PullRequestTarget: "main", + }) + if err != nil { + t.Fatalf("create policy: %v", err) + } + + result, err := svc.CreateTask(ctx, &CreateTaskRequest{ + WorkspaceID: "ws-policy-fresh", WorkflowID: workflowID, Title: "Fresh branch", + Repositories: []TaskRepositoryInput{{ + RepositoryID: "repo-policy-fresh", BranchPolicyID: policy.ID, BaseBranch: "main", + }}, + }) + if err != nil { + t.Fatalf("create task: %v", err) + } + if _, err := svc.UpdateRepositoryBranchPolicy(ctx, policy.ID, &UpdateRepositoryBranchPolicyRequest{ + BaseBranch: stringPointer("develop"), + }); err != nil { + t.Fatalf("update policy after task creation: %v", err) + } + + if err := svc.ReplaceTaskRepositories(ctx, result.Task.ID, "ws-policy-fresh", []TaskRepositoryInput{{ + RepositoryID: "repo-policy-fresh", BranchPolicyID: policy.ID, BaseBranch: "feature/fresh-branch", + PreserveBaseBranch: true, + }}); err != nil { + t.Fatalf("replace task repositories: %v", err) + } + + linked, err := repo.ListTaskRepositories(ctx, result.Task.ID) + if err != nil { + t.Fatalf("list task repositories: %v", err) + } + if len(linked) != 1 { + t.Fatalf("task repositories = %+v, want one association", linked) + } + if linked[0].BaseBranch != "feature/fresh-branch" { + t.Fatalf("effective base branch = %q, want generated fresh branch", linked[0].BaseBranch) + } + if linked[0].BranchPolicyBaseBranch != "main" { + t.Fatalf("policy snapshot base branch = %q, want main", linked[0].BranchPolicyBaseBranch) + } +} + +func stringPointer(value string) *string { return &value } diff --git a/apps/backend/internal/task/service/service_requests.go b/apps/backend/internal/task/service/service_requests.go index ca6d6bfb23..af122ce53a 100644 --- a/apps/backend/internal/task/service/service_requests.go +++ b/apps/backend/internal/task/service/service_requests.go @@ -13,6 +13,7 @@ type TaskRepositoryInput struct { RepositoryID string `json:"repository_id"` BaseBranch string `json:"base_branch"` CheckoutBranch string `json:"checkout_branch,omitempty"` + BranchPolicyID string `json:"branch_policy_id,omitempty"` PRNumber int `json:"pr_number,omitempty"` // GitHub PR number when CheckoutBranch is a PR head; persisted into task_repositories.metadata["pr_number"]. LocalPath string `json:"local_path,omitempty"` Name string `json:"name,omitempty"` @@ -26,6 +27,13 @@ type TaskRepositoryInput struct { ProviderOwner string `json:"provider_owner,omitempty"` ProviderName string `json:"provider_name,omitempty"` + // PreserveBaseBranch keeps an effective branch produced after policy + // resolution (for example, the branch created by the local fresh-branch + // flow) when task repositories are recreated. It is internal-only: normal + // task creation must always anchor a policy-backed repository to the + // policy's immutable base branch. + PreserveBaseBranch bool `json:"-"` + // RemoteContribution is server-authored after provider resolution. It is // intentionally excluded from JSON request surfaces; callers must not be // able to forge a writable source binding. @@ -54,7 +62,8 @@ type TaskRepositoryInput struct { // resolver. Plugin descriptors must come through the authenticated plugin // Host Tasks.Create path, which sets this marker only after validating the // active plugin's provider ownership and clone origin. - TrustedProviderDescriptor bool `json:"-"` + TrustedProviderDescriptor bool `json:"-"` + BranchPolicySnapshot *models.RepositoryBranchPolicy `json:"-"` } // CreateTaskRequest contains the data for creating a new task diff --git a/apps/backend/internal/task/service/service_task_branch_policy_snapshot.go b/apps/backend/internal/task/service/service_task_branch_policy_snapshot.go new file mode 100644 index 0000000000..4319219680 --- /dev/null +++ b/apps/backend/internal/task/service/service_task_branch_policy_snapshot.go @@ -0,0 +1,115 @@ +package service + +import ( + "context" + "errors" + "fmt" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" +) + +// validateTaskRepositoryPolicies resolves every explicit policy before the task +// row is inserted. This keeps a stale or cross-repository policy from creating +// a task that cannot later materialize its requested branch workflow. +func (s *Service) validateTaskRepositoryPolicies(ctx context.Context, workspaceID string, inputs []TaskRepositoryInput) error { + for index := range inputs { + input := &inputs[index] + if input.BranchPolicyID == "" { + continue + } + if input.RepositoryID == "" { + return fmt.Errorf("%w: branch policy selection requires a repository id", ErrInvalidRepositoryBranchPolicy) + } + repository, err := s.repoEntities.GetRepository(ctx, input.RepositoryID) + if err != nil { + return err + } + if repository == nil || repository.WorkspaceID != workspaceID { + return repoerrors.ErrRepositoryNotFound + } + policy, err := s.resolveTaskRepositoryPolicy(ctx, input.RepositoryID, *input) + if err != nil { + return err + } + if input.RemoteContribution != nil && policy.BaseBranch != input.RemoteContribution.BaseBranch { + return fmt.Errorf("remote contribution base branch %q does not match branch policy base branch %q", input.RemoteContribution.BaseBranch, policy.BaseBranch) + } + input.BranchPolicySnapshot = cloneBranchPolicy(policy) + } + return nil +} + +// preserveTaskRepositoryPolicySnapshots carries the immutable policy fields +// across an association replacement. Fresh-branch creation replaces the row +// after the branch exists, so resolving the policy again could otherwise pick +// up a later edit or fail after the policy was deleted. +func preserveTaskRepositoryPolicySnapshots(inputs []TaskRepositoryInput, existing []*models.TaskRepository) { + for index := range inputs { + input := &inputs[index] + if input.BranchPolicyID == "" || input.BranchPolicySnapshot != nil { + continue + } + for _, repository := range existing { + if repository.RepositoryID != input.RepositoryID || repository.BranchPolicyID != input.BranchPolicyID { + continue + } + if snapshot := branchPolicyFromTaskRepository(repository); snapshot != nil { + input.BranchPolicySnapshot = snapshot + } + break + } + } +} + +func branchPolicyFromTaskRepository(repository *models.TaskRepository) *models.RepositoryBranchPolicy { + if repository == nil || repository.BranchPolicyID == "" || repository.BranchPolicyName == "" || + repository.BranchPolicyBaseBranch == "" || repository.BranchPolicyBranchTemplate == "" || + repository.BranchPolicyPullRequestTarget == "" { + return nil + } + return &models.RepositoryBranchPolicy{ + ID: repository.BranchPolicyID, + RepositoryID: repository.RepositoryID, + Name: repository.BranchPolicyName, + BaseBranch: repository.BranchPolicyBaseBranch, + BranchTemplate: repository.BranchPolicyBranchTemplate, + PullRequestTarget: repository.BranchPolicyPullRequestTarget, + } +} + +func (s *Service) resolveTaskRepositoryPolicy(ctx context.Context, repositoryID string, input TaskRepositoryInput) (*models.RepositoryBranchPolicy, error) { + if input.BranchPolicyID == "" { + return nil, nil + } + if input.BranchPolicySnapshot != nil { + if input.BranchPolicySnapshot.ID == input.BranchPolicyID && input.BranchPolicySnapshot.RepositoryID == repositoryID { + return cloneBranchPolicy(input.BranchPolicySnapshot), nil + } + } + if s.branchPolicies == nil { + return nil, ErrRepositoryBranchPolicyStoreMissing + } + policy, err := s.branchPolicies.GetRepositoryBranchPolicy(ctx, input.BranchPolicyID) + if err != nil { + if errors.Is(err, repoerrors.ErrRepositoryBranchPolicyNotFound) { + return nil, fmt.Errorf("%w: %w", ErrInvalidRepositoryBranchPolicy, ErrRepositoryBranchPolicyStale) + } + return nil, fmt.Errorf("%w: resolve selected policy: %v", ErrInvalidRepositoryBranchPolicy, err) + } + if policy == nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidRepositoryBranchPolicy, ErrRepositoryBranchPolicyStale) + } + if policy.RepositoryID != repositoryID { + return nil, fmt.Errorf("%w: selected policy does not belong to repository", ErrInvalidRepositoryBranchPolicy) + } + return cloneBranchPolicy(policy), nil +} + +func cloneBranchPolicy(policy *models.RepositoryBranchPolicy) *models.RepositoryBranchPolicy { + if policy == nil { + return nil + } + copy := *policy + return © +} diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index 9bfb283401..86a2d11a84 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -252,6 +252,9 @@ func (s *Service) prepareTaskForCreation(ctx context.Context, req *CreateTaskReq if err := s.inheritParentRepositories(ctx, req); err != nil { return nil, err } + if err := s.validateTaskRepositoryPolicies(ctx, req.WorkspaceID, req.Repositories); err != nil { + return nil, err + } // For office tasks, resolve workflow from workspace if isOfficeRequest(req) && req.WorkflowID == "" { @@ -831,6 +834,19 @@ func (s *Service) createTaskRepositories(ctx context.Context, taskID, workspaceI if repositoryID == "" { return fmt.Errorf("repository_id is required") } + policy, err := s.resolveTaskRepositoryPolicy(ctx, repositoryID, repoInput) + if err != nil { + return err + } + if policy != nil { + if repoInput.RemoteContribution != nil { + if policy.BaseBranch != repoInput.RemoteContribution.BaseBranch { + return fmt.Errorf("remote contribution base branch %q does not match branch policy base branch %q", repoInput.RemoteContribution.BaseBranch, policy.BaseBranch) + } + } else if !repoInput.PreserveBaseBranch { + baseBranch = policy.BaseBranch + } + } // Multi-branch validation: the same repository may appear multiple // times in a task on different branches. Identity is // (repository_id, base_branch, checkout_branch) — base_branch matters @@ -874,6 +890,13 @@ func (s *Service) createTaskRepositories(ctx context.Context, taskID, workspaceI Position: i, Metadata: metadata, } + if policy != nil { + taskRepo.BranchPolicyID = policy.ID + taskRepo.BranchPolicyName = policy.Name + taskRepo.BranchPolicyBaseBranch = policy.BaseBranch + taskRepo.BranchPolicyBranchTemplate = policy.BranchTemplate + taskRepo.BranchPolicyPullRequestTarget = policy.PullRequestTarget + } if err := s.taskRepos.CreateTaskRepository(ctx, taskRepo); err != nil { s.logger.Error("failed to create task repository", zap.Error(err)) return err @@ -1522,6 +1545,15 @@ func (s *Service) ReplaceTaskRepositories(ctx context.Context, taskID, workspace // replaceTaskRepositories deletes all existing task-repository associations and recreates them. func (s *Service) replaceTaskRepositories(ctx context.Context, taskID, workspaceID string, repositories []TaskRepositoryInput) error { + existing, err := s.taskRepos.ListTaskRepositories(ctx, taskID) + if err != nil { + s.logger.Error("failed to load existing task repositories", zap.Error(err)) + return err + } + preserveTaskRepositoryPolicySnapshots(repositories, existing) + if err := s.validateTaskRepositoryPolicies(ctx, workspaceID, repositories); err != nil { + return err + } if err := s.taskRepos.DeleteTaskRepositoriesByTask(ctx, taskID); err != nil { s.logger.Error("failed to delete task repositories", zap.Error(err)) return err diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index ae3b966675..bd20787393 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -199,6 +199,7 @@ func createTestServiceWithSessionsRepo( GitSnapshots: repo, RepoEntities: repo, RepositorySets: repo, + BranchPolicies: repo, RepositoryCleanup: repo, Executors: repo, Environments: repo, diff --git a/apps/backend/internal/task/service/service_turns.go b/apps/backend/internal/task/service/service_turns.go index d3b683e725..a5fdee100c 100644 --- a/apps/backend/internal/task/service/service_turns.go +++ b/apps/backend/internal/task/service/service_turns.go @@ -960,11 +960,15 @@ func (s *Service) populateWorkspaceRepositorySpecs(ctx context.Context, taskID s branchPlans := worktree.BuildBranchIdentityPlans(workspaceBranchIdentityInputs(projections)) for index, projection := range projections { taskRepository, repository := projection.taskRepository, projection.repository + branchTemplate := repository.WorktreeBranchTemplate + if taskRepository.BranchPolicyBranchTemplate != "" { + branchTemplate = taskRepository.BranchPolicyBranchTemplate + } spec := lifecycle.WorkspaceRepositorySpec{ RepositoryID: taskRepository.RepositoryID, RepositoryPath: repository.LocalPath, RepoName: projection.repoName, BaseBranch: taskRepository.BaseBranch, DefaultBranch: repository.DefaultBranch, CheckoutBranch: taskRepository.CheckoutBranch, WorktreeBranchPrefix: repository.WorktreeBranchPrefix, - WorktreeBranchTemplate: repository.WorktreeBranchTemplate, PullBeforeWorktree: repository.PullBeforeWorktree, + WorktreeBranchTemplate: branchTemplate, PullBeforeWorktree: repository.PullBeforeWorktree, } if worktree := worktreesByIdentity[workspaceWorktreeKey{repositoryID: taskRepository.RepositoryID, branchSlug: branchPlans[index].IdentitySlug}]; worktree != nil { spec.WorktreeID = worktree.WorktreeID diff --git a/apps/backend/pkg/api/v1/task.go b/apps/backend/pkg/api/v1/task.go index 36d647aeab..83509585b4 100644 --- a/apps/backend/pkg/api/v1/task.go +++ b/apps/backend/pkg/api/v1/task.go @@ -166,8 +166,9 @@ type Task struct { // TaskRepositoryInput for creating/updating task repositories type TaskRepositoryInput struct { - RepositoryID string `json:"repository_id" binding:"required"` - BaseBranch string `json:"base_branch" binding:"required"` + RepositoryID string `json:"repository_id" binding:"required"` + BaseBranch string `json:"base_branch" binding:"required"` + BranchPolicyID string `json:"branch_policy_id,omitempty"` } // CreateTaskRequest for creating a new task diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index d68868905b..35d9e8d28e 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -34,6 +34,14 @@ const ( ActionRepositorySetUpdate = "repository_set.update" ActionRepositorySetDelete = "repository_set.delete" + // Repository branch policy actions + ActionRepositoryBranchPolicyList = "repository_branch_policy.list" + ActionRepositoryBranchPolicyCreate = "repository_branch_policy.create" + ActionRepositoryBranchPolicyGet = "repository_branch_policy.get" + ActionRepositoryBranchPolicyUpdate = "repository_branch_policy.update" + ActionRepositoryBranchPolicyDelete = "repository_branch_policy.delete" + ActionRepositoryBranchPolicyGitflow = "repository_branch_policy.gitflow" + // Repository Script actions ActionRepositoryScriptList = "repository.script.list" ActionRepositoryScriptCreate = "repository.script.create" @@ -189,84 +197,87 @@ const ( // Native code review. The *.run / *.cancel / *.get / *.finding.update / // *.clear actions are client requests; the rest are server notifications. - ActionTaskReviewRun = "task.review.run" - ActionTaskReviewCancel = "task.review.cancel" - ActionTaskReviewGet = "task.review.get" - ActionTaskReviewFindingUpdate = "task.review.finding.update" - ActionTaskReviewClear = "task.review.clear" - ActionTaskReviewRunUpdated = "task.review.run_updated" - ActionTaskReviewFindingsPublished = "task.review.findings_published" - ActionTaskReviewFindingUpdated = "task.review.finding_updated" - ActionTaskReviewCleared = "task.review.cleared" - ActionAgentUpdated = "agent.updated" - ActionAgentAvailableUpdated = "agent.available.updated" - ActionAgentInstallStarted = "agent.install.started" - ActionAgentInstallOutput = "agent.install.output" - ActionAgentInstallFinished = "agent.install.finished" - ActionAgentUpdateStarted = "agent.update.started" - ActionAgentUpdateOutput = "agent.update.output" - ActionAgentUpdateFinished = "agent.update.finished" - ActionWorkspaceCreated = "workspace.created" - ActionWorkspaceUpdated = "workspace.updated" - ActionWorkspaceDeleted = "workspace.deleted" - ActionWorkflowCreated = "workflow.created" - ActionWorkflowUpdated = "workflow.updated" - ActionWorkflowDeleted = "workflow.deleted" - ActionWorkflowStepCreated = "workflow.step.created" - ActionWorkflowStepUpdated = "workflow.step.updated" - ActionWorkflowStepDeleted = "workflow.step.deleted" - ActionSessionMessageAdded = "session.message.added" - ActionSessionMessageUpdated = "session.message.updated" - ActionSessionMessageDeleted = "session.message.deleted" - ActionSessionStateChanged = "session.state_changed" - ActionSessionActivityChanged = "session.activity_changed" - ActionSessionCancellationChanged = "session.cancellation_changed" - ActionTaskStatusSummaryUpdated = "task.status_summary.updated" - ActionSessionAgentctlStarting = "session.agentctl_starting" - ActionSessionAgentctlReady = "session.agentctl_ready" - ActionSessionAgentctlError = "session.agentctl_error" - ActionSessionTurnStarted = "session.turn.started" - ActionSessionTurnCompleted = "session.turn.completed" - ActionSessionAvailableCommands = "session.available_commands" - ActionSessionModeChanged = "session.mode_changed" - ActionSessionAgentCapabilities = "session.agent_capabilities" - ActionSessionModelsUpdated = "session.models_updated" - ActionSessionModelFallback = "session.model_fallback" - ActionSessionModelSelectionWarning = "session.model_selection_warning" - ActionSessionMCPStatusUpdated = "session.mcp_status_updated" - ActionSessionInfoUpdated = "session.info_updated" - ActionSessionSetMode = "session.set_mode" - ActionSessionTodosUpdated = "session.todos_updated" - ActionSessionPromptUsage = "session.prompt_usage" - ActionSessionPollModeChanged = "session.poll_mode_changed" - ActionSessionRouteChanging = "session.route_changing" - ActionSessionRouteChanged = "session.route_changed" - ActionSessionCapabilitiesReplaced = "session.capabilities_replaced" - ActionSessionRouteWaiting = "session.route_waiting" - ActionSessionRoutePending = "session.route_pending" - ActionInputRequested = "input.requested" - ActionRepositoryCreated = "repository.created" - ActionRepositoryUpdated = "repository.updated" - ActionRepositoryDeleted = "repository.deleted" - ActionRepositorySetCreated = "repository_set.created" - ActionRepositorySetUpdated = "repository_set.updated" - ActionRepositorySetDeleted = "repository_set.deleted" - ActionRepositoryScriptCreated = "repository.script.created" - ActionRepositoryScriptUpdated = "repository.script.updated" - ActionRepositoryScriptDeleted = "repository.script.deleted" - ActionExecutorCreated = "executor.created" - ActionExecutorUpdated = "executor.updated" - ActionExecutorDeleted = "executor.deleted" - ActionEnvironmentCreated = "environment.created" - ActionEnvironmentUpdated = "environment.updated" - ActionEnvironmentDeleted = "environment.deleted" - ActionExecutorProfileCreated = "executor.profile.created" - ActionExecutorProfileUpdated = "executor.profile.updated" - ActionExecutorProfileDeleted = "executor.profile.deleted" - ActionExecutorPrepareProgress = "executor.prepare.progress" - ActionExecutorPrepareCompleted = "executor.prepare.completed" - ActionSystemMetricsUpdated = "system.metrics.updated" - ActionUpdateAvailable = "system.update_available" + ActionTaskReviewRun = "task.review.run" + ActionTaskReviewCancel = "task.review.cancel" + ActionTaskReviewGet = "task.review.get" + ActionTaskReviewFindingUpdate = "task.review.finding.update" + ActionTaskReviewClear = "task.review.clear" + ActionTaskReviewRunUpdated = "task.review.run_updated" + ActionTaskReviewFindingsPublished = "task.review.findings_published" + ActionTaskReviewFindingUpdated = "task.review.finding_updated" + ActionTaskReviewCleared = "task.review.cleared" + ActionAgentUpdated = "agent.updated" + ActionAgentAvailableUpdated = "agent.available.updated" + ActionAgentInstallStarted = "agent.install.started" + ActionAgentInstallOutput = "agent.install.output" + ActionAgentInstallFinished = "agent.install.finished" + ActionAgentUpdateStarted = "agent.update.started" + ActionAgentUpdateOutput = "agent.update.output" + ActionAgentUpdateFinished = "agent.update.finished" + ActionWorkspaceCreated = "workspace.created" + ActionWorkspaceUpdated = "workspace.updated" + ActionWorkspaceDeleted = "workspace.deleted" + ActionWorkflowCreated = "workflow.created" + ActionWorkflowUpdated = "workflow.updated" + ActionWorkflowDeleted = "workflow.deleted" + ActionWorkflowStepCreated = "workflow.step.created" + ActionWorkflowStepUpdated = "workflow.step.updated" + ActionWorkflowStepDeleted = "workflow.step.deleted" + ActionSessionMessageAdded = "session.message.added" + ActionSessionMessageUpdated = "session.message.updated" + ActionSessionMessageDeleted = "session.message.deleted" + ActionSessionStateChanged = "session.state_changed" + ActionSessionActivityChanged = "session.activity_changed" + ActionSessionCancellationChanged = "session.cancellation_changed" + ActionTaskStatusSummaryUpdated = "task.status_summary.updated" + ActionSessionAgentctlStarting = "session.agentctl_starting" + ActionSessionAgentctlReady = "session.agentctl_ready" + ActionSessionAgentctlError = "session.agentctl_error" + ActionSessionTurnStarted = "session.turn.started" + ActionSessionTurnCompleted = "session.turn.completed" + ActionSessionAvailableCommands = "session.available_commands" + ActionSessionModeChanged = "session.mode_changed" + ActionSessionAgentCapabilities = "session.agent_capabilities" + ActionSessionModelsUpdated = "session.models_updated" + ActionSessionModelFallback = "session.model_fallback" + ActionSessionModelSelectionWarning = "session.model_selection_warning" + ActionSessionMCPStatusUpdated = "session.mcp_status_updated" + ActionSessionInfoUpdated = "session.info_updated" + ActionSessionSetMode = "session.set_mode" + ActionSessionTodosUpdated = "session.todos_updated" + ActionSessionPromptUsage = "session.prompt_usage" + ActionSessionPollModeChanged = "session.poll_mode_changed" + ActionSessionRouteChanging = "session.route_changing" + ActionSessionRouteChanged = "session.route_changed" + ActionSessionCapabilitiesReplaced = "session.capabilities_replaced" + ActionSessionRouteWaiting = "session.route_waiting" + ActionSessionRoutePending = "session.route_pending" + ActionInputRequested = "input.requested" + ActionRepositoryCreated = "repository.created" + ActionRepositoryUpdated = "repository.updated" + ActionRepositoryDeleted = "repository.deleted" + ActionRepositorySetCreated = "repository_set.created" + ActionRepositorySetUpdated = "repository_set.updated" + ActionRepositorySetDeleted = "repository_set.deleted" + ActionRepositoryBranchPolicyCreated = "repository_branch_policy.created" + ActionRepositoryBranchPolicyUpdated = "repository_branch_policy.updated" + ActionRepositoryBranchPolicyDeleted = "repository_branch_policy.deleted" + ActionRepositoryScriptCreated = "repository.script.created" + ActionRepositoryScriptUpdated = "repository.script.updated" + ActionRepositoryScriptDeleted = "repository.script.deleted" + ActionExecutorCreated = "executor.created" + ActionExecutorUpdated = "executor.updated" + ActionExecutorDeleted = "executor.deleted" + ActionEnvironmentCreated = "environment.created" + ActionEnvironmentUpdated = "environment.updated" + ActionEnvironmentDeleted = "environment.deleted" + ActionExecutorProfileCreated = "executor.profile.created" + ActionExecutorProfileUpdated = "executor.profile.updated" + ActionExecutorProfileDeleted = "executor.profile.deleted" + ActionExecutorPrepareProgress = "executor.prepare.progress" + ActionExecutorPrepareCompleted = "executor.prepare.completed" + ActionSystemMetricsUpdated = "system.metrics.updated" + ActionUpdateAvailable = "system.update_available" ActionAgentProfileDeleted = "agent.profile.deleted" ActionAgentProfileCreated = "agent.profile.created" diff --git a/apps/web/app/office/tasks/[id]/office-dockview-layout.tsx b/apps/web/app/office/tasks/[id]/office-dockview-layout.tsx index 6c6b91aabc..bc0f2c56f6 100644 --- a/apps/web/app/office/tasks/[id]/office-dockview-layout.tsx +++ b/apps/web/app/office/tasks/[id]/office-dockview-layout.tsx @@ -7,6 +7,10 @@ import { useDockviewStore } from "@/lib/state/dockview-store"; import { applyLayout } from "@/lib/state/layout-manager"; import { setupGroupTracking, setupPortalCleanup } from "@/components/task/dockview-layout-setup"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { + resolveTaskPullRequestProps, + selectWorkspaceRepositories, +} from "@/components/task/task-page-content-helpers"; import { CENTER_GROUP, RIGHT_TOP_GROUP, @@ -29,6 +33,7 @@ import { ContextMenuTab, renderPanel, } from "@/components/task/dockview-shared"; +import type { Task as OfficeTask } from "./types"; // --------------------------------------------------------------------------- // OFFICE LAYOUT — no sidebar, no persistence, no multi-session @@ -57,13 +62,22 @@ function officeLayout(): LayoutState { type OfficeDockviewLayoutProps = { taskId: string; sessionId: string | null; + task?: OfficeTask; }; -export function OfficeDockviewLayout({ taskId, sessionId }: OfficeDockviewLayoutProps) { +export function OfficeDockviewLayout({ taskId, sessionId, task }: OfficeDockviewLayoutProps) { const setApi = useDockviewStore((s) => s.setApi); const appStore = useAppStoreApi(); const setActiveSession = useAppStore((s) => s.setActiveSession); const setActiveTask = useAppStore((s) => s.setActiveTask); + const storedOfficeTask = useAppStore((s) => + s.office.tasks.items.find((item) => item.id === taskId), + ); + const officeTask = task ?? storedOfficeTask; + const workspaceRepositories = useAppStore((s) => + selectWorkspaceRepositories(s.repositories.itemsByWorkspaceId, officeTask?.workspaceId), + ); + const pullRequestProps = resolveTaskPullRequestProps(officeTask ?? null, workspaceRepositories); // Wire office session into the global store so shared panels can read it. useEffect(() => { @@ -135,7 +149,14 @@ export function OfficeDockviewLayout({ taskId, sessionId }: OfficeDockviewLayout ); return ( - +
{t("office:agentSessionEnded")}
)} - + ); } diff --git a/apps/web/app/settings/workspace/workspace-repositories-client.tsx b/apps/web/app/settings/workspace/workspace-repositories-client.tsx index 5a422dd058..e2a6ecd647 100644 --- a/apps/web/app/settings/workspace/workspace-repositories-client.tsx +++ b/apps/web/app/settings/workspace/workspace-repositories-client.tsx @@ -572,6 +572,7 @@ export function WorkspaceRepositoriesClient({ ; + repositories?: Array<{ + id: string; + repository_id: string; + base_branch?: string; + checkout_branch?: string; + branch_policy_id?: string; + branch_policy_name?: string; + branch_policy_base_branch?: string; + branch_policy_branch_template?: string; + branch_policy_pull_request_target?: string; + position: number; + }>; sessionCount?: number | null; primarySessionId?: string | null; /** diff --git a/apps/web/components/settings/repository-branch-policies.tsx b/apps/web/components/settings/repository-branch-policies.tsx new file mode 100644 index 0000000000..b624829fa5 --- /dev/null +++ b/apps/web/components/settings/repository-branch-policies.tsx @@ -0,0 +1,556 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconChevronDown, IconPencil, IconPlus, IconTrash } from "@tabler/icons-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@kandev/ui/alert-dialog"; +import { Button } from "@kandev/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@kandev/ui/dialog"; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, +} from "@kandev/ui/drawer"; +import { useToast } from "@/components/toast-provider"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import { useBranches } from "@/hooks/domains/workspace/use-repository-branches"; +import { useRepositoryBranchPolicies } from "@/hooks/domains/workspace/use-repository-branch-policies"; +import type { Repository, RepositoryBranchPolicy } from "@/lib/types/http"; +import { branchToOption, sortBranches } from "@/components/task-create-dialog-branch-options"; +import { + BranchPolicyBranchField, + PolicyFields, + draftFromPolicy, + type PolicyDraft, +} from "@/components/settings/repository-branch-policy-fields"; + +const TOUCH_TARGET_CLASS = "min-h-11"; +const CANCEL_LABEL_KEY = "common:cancel"; + +function PolicySurface({ + open, + onOpenChange, + title, + description, + draft, + setDraft, + branches, + refreshBranches, + branchesLoading, + idPrefix, + onSubmit, + loading, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: string; + draft: PolicyDraft; + setDraft: (next: PolicyDraft) => void; + branches: ReturnType["branches"]; + refreshBranches?: () => void; + branchesLoading: boolean; + idPrefix: string; + onSubmit: () => void; + loading: boolean; +}) { + const { t } = useTranslation(); + const { isMobile } = useResponsiveBreakpoint(); + const formId = `${idPrefix}-form`; + const body = ( +
{ + event.preventDefault(); + onSubmit(); + }} + className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4" + > + + + ); + const footer = isMobile ? ( + + + + + ) : ( + + + + + ); + if (isMobile) { + return ( + + + + {title} + {description} + + {body} + {footer} + + + ); + } + return ( + + + + {title} + {description} + + {body} + {footer} + + + ); +} + +// eslint-disable-next-line max-lines-per-function -- Shares validated fields and one responsive surface. +function GitflowSurface({ + open, + onOpenChange, + productionBranch, + developmentBranch, + setProductionBranch, + setDevelopmentBranch, + branches, + branchOptions, + refreshBranches, + idPrefix, + branchesLoading, + onSubmit, + loading, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + productionBranch: string; + developmentBranch: string; + setProductionBranch: (value: string) => void; + setDevelopmentBranch: (value: string) => void; + branchOptions: string[]; + branches: ReturnType["branches"]; + refreshBranches?: () => void; + idPrefix: string; + branchesLoading: boolean; + onSubmit: () => void; + loading: boolean; +}) { + const { t } = useTranslation(); + const { isMobile } = useResponsiveBreakpoint(); + const fields = ( +
+

+ {t("workspaces:branchPolicyGitflowDescription")} +

+ + +

{t("workspaces:branchPolicyGitflowPreview")}

+
+ ); + const valid = + !branchesLoading && + productionBranch.trim() !== "" && + developmentBranch.trim() !== "" && + productionBranch.trim() !== developmentBranch.trim() && + branchOptions.includes(productionBranch.trim()) && + branchOptions.includes(developmentBranch.trim()); + const footer = isMobile ? ( + + + + + ) : ( + + + + + ); + if (isMobile) { + return ( + + + + {t("workspaces:branchPolicyGitflowTitle")} + {t("workspaces:branchPolicyGitflowDescription")} + +
{fields}
+ {footer} +
+
+ ); + } + return ( + + + + {t("workspaces:branchPolicyGitflowTitle")} + {t("workspaces:branchPolicyGitflowDescription")} + + {fields} + {footer} + + + ); +} + +// eslint-disable-next-line max-lines-per-function -- Coordinates policy CRUD, Gitflow seeding, and editors. +export function RepositoryBranchPolicies({ + repository, + workspaceId, +}: { + repository: Repository; + workspaceId: string; +}) { + const { t } = useTranslation(); + const { toast } = useToast(); + const isSaved = !repository.id.startsWith("temp-repo-"); + const idPrefix = `branch-policy-${repository.id.replace(/[^a-zA-Z0-9_-]/g, "-")}`; + const { policies, hasError, isLoading, refresh, create, update, remove, seedGitflow } = + useRepositoryBranchPolicies(repository.id, isSaved); + const { + branches, + isLoading: branchesLoading, + refresh: refreshBranches, + } = useBranches( + isSaved ? { kind: "id", workspaceId, repositoryId: repository.id } : null, + isSaved, + ); + const branchOptions = useMemo(() => { + const names = sortBranches(branches).map((branch) => branchToOption(branch).value); + return [...new Set(names)]; + }, [branches]); + const [editorPolicy, setEditorPolicy] = useState(); + const [draft, setDraft] = useState(() => draftFromPolicy()); + const [editorOpen, setEditorOpen] = useState(false); + const [gitflowOpen, setGitflowOpen] = useState(false); + const [productionBranch, setProductionBranch] = useState(""); + const [developmentBranch, setDevelopmentBranch] = useState(""); + const [pending, setPending] = useState(false); + const [deletePolicy, setDeletePolicy] = useState(); + + const openCreate = () => { + setEditorPolicy(undefined); + setDraft(draftFromPolicy()); + setEditorOpen(true); + }; + const openEdit = (policy: RepositoryBranchPolicy) => { + setEditorPolicy(policy); + setDraft(draftFromPolicy(policy)); + setEditorOpen(true); + }; + const submitPolicy = async () => { + setPending(true); + try { + if (editorPolicy) await update(editorPolicy.id, draft); + else await create(draft); + setEditorOpen(false); + toast({ title: t("workspaces:branchPolicySaved"), variant: "success" }); + } catch (error) { + toast({ + title: t("workspaces:branchPolicySaveFailed"), + description: error instanceof Error ? error.message : t("common:requestFailed"), + variant: "error", + }); + } finally { + setPending(false); + } + }; + const submitGitflow = async () => { + setPending(true); + try { + await seedGitflow(productionBranch.trim(), developmentBranch.trim()); + setGitflowOpen(false); + toast({ title: t("workspaces:branchPolicyGitflowCreated"), variant: "success" }); + } catch (error) { + toast({ + title: t("workspaces:branchPolicyGitflowFailed"), + description: error instanceof Error ? error.message : t("common:requestFailed"), + variant: "error", + }); + } finally { + setPending(false); + } + }; + const confirmDelete = async () => { + if (!deletePolicy) return; + setPending(true); + try { + await remove(deletePolicy.id); + setDeletePolicy(undefined); + toast({ title: t("workspaces:branchPolicyDeleted"), variant: "success" }); + } catch (error) { + toast({ + title: t("workspaces:branchPolicyDeleteFailed"), + description: error instanceof Error ? error.message : t("common:requestFailed"), + variant: "error", + }); + } finally { + setPending(false); + } + }; + + return ( +
+ + + + {t("workspaces:branchPoliciesTitle")} + + {policies.length} + + + +
+

{t("workspaces:branchPoliciesDescription")}

+ {!isSaved ? ( +

+ {t("workspaces:branchPoliciesSaveRepositoryFirst")} +

+ ) : null} + {isSaved && hasError ? ( +
+

{t("workspaces:branchPoliciesLoadFailed")}

+ +
+ ) : null} + {isSaved && !hasError && policies.length === 0 ? ( +

{t("workspaces:branchPoliciesEmpty")}

+ ) : null} +
+ {policies.map((policy) => ( +
+
+

{policy.name}

+

+ {t("workspaces:branchPolicySummary", { + base: policy.base_branch, + template: policy.branch_template, + target: policy.pull_request_target, + })} +

+ {policy.description ? ( +

{policy.description}

+ ) : null} +
+
+ + +
+
+ ))} +
+ {isSaved ? ( +
+ + {!hasError && policies.length === 0 ? ( + + ) : null} +
+ ) : null} +
+ void submitPolicy()} + loading={pending} + /> + void submitGitflow()} + loading={pending} + /> + { + if (!open) setDeletePolicy(undefined); + }} + > + + + + {t("workspaces:branchPolicyDeleteTitle", { name: deletePolicy?.name ?? "" })} + + + {t("workspaces:branchPolicyDeleteDescription")} + + + + {t(CANCEL_LABEL_KEY)} + void confirmDelete()} + > + {t("workspaces:branchPolicyDeleteConfirm")} + + + + + {isLoading ? ( +

+ {t("workspaces:branchPoliciesLoading")} +

+ ) : null} +
+ ); +} diff --git a/apps/web/components/settings/repository-branch-policy-fields.test.tsx b/apps/web/components/settings/repository-branch-policy-fields.test.tsx new file mode 100644 index 0000000000..cf2b3075d9 --- /dev/null +++ b/apps/web/components/settings/repository-branch-policy-fields.test.tsx @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { TooltipProvider } from "@kandev/ui/tooltip"; +import type { Branch } from "@/lib/types/http"; +import { BranchPolicyBranchPicker } from "./repository-branch-policy-fields"; + +vi.mock("@/hooks/use-compact-task-chrome", () => ({ useTouchDrawer: () => false })); + +afterEach(cleanup); + +describe("BranchPolicyBranchPicker", () => { + it("filters all local and remote branches and refreshes from the picker", () => { + const onChange = vi.fn(); + const onRefresh = vi.fn(); + const branches: Branch[] = [ + { name: "main", type: "local" }, + { name: "main", type: "remote", remote: "origin" }, + { name: "release/candidate", type: "remote", remote: "upstream" }, + ]; + + render( + + + , + ); + + fireEvent.click(screen.getByRole("combobox", { name: "Base branch" })); + expect(screen.getByPlaceholderText("Search branches...")).toBeTruthy(); + expect(screen.getByRole("option", { name: /^main local/ })).toBeTruthy(); + expect(screen.getByRole("option", { name: /^origin\/main origin/ })).toBeTruthy(); + + fireEvent.change(screen.getByPlaceholderText("Search branches..."), { + target: { value: "candidate" }, + }); + const listbox = screen.getByRole("listbox"); + expect( + within(listbox).getByRole("option", { name: /^upstream\/release\/candidate upstream/ }), + ).toBeTruthy(); + expect(within(listbox).queryByRole("option", { name: /^main local/ })).toBeNull(); + + fireEvent.click(screen.getByTestId("branch-refresh-button")); + expect(onRefresh).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/components/settings/repository-branch-policy-fields.tsx b/apps/web/components/settings/repository-branch-policy-fields.tsx new file mode 100644 index 0000000000..d07656d3c5 --- /dev/null +++ b/apps/web/components/settings/repository-branch-policy-fields.tsx @@ -0,0 +1,269 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconInfoCircle } from "@tabler/icons-react"; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@kandev/ui/drawer"; +import { Input } from "@kandev/ui/input"; +import { Label } from "@kandev/ui/label"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; +import { BranchSelector } from "@/components/task-create-dialog-selectors"; +import { + branchToOption, + buildBranchKeywords, + sortBranches, +} from "@/components/task-create-dialog-branch-options"; +import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; +import type { Branch, RepositoryBranchPolicy } from "@/lib/types/http"; + +export type PolicyDraft = Omit< + RepositoryBranchPolicy, + "id" | "repository_id" | "created_at" | "updated_at" +>; + +export const DEFAULT_POLICY_DRAFT: PolicyDraft = { + name: "", + description: "", + base_branch: "", + // i18n-exempt: branch template protocol + branch_template: "feature/{title}-{suffix}", + pull_request_target: "", +}; + +export function draftFromPolicy(policy?: RepositoryBranchPolicy): PolicyDraft { + return policy + ? { + name: policy.name, + description: policy.description, + base_branch: policy.base_branch, + branch_template: policy.branch_template, + pull_request_target: policy.pull_request_target, + } + : { ...DEFAULT_POLICY_DRAFT }; +} + +export function FieldHelp({ label, description }: { label: string; description: string }) { + const usesTouchDrawer = useTouchDrawer(); + const [open, setOpen] = useState(false); + const button = ( + + ); + if (!usesTouchDrawer) { + return ( + + + {button} + {description} + + + ); + } + return ( + + {button} + + + {label} + {description} + + + + ); +} + +function selectedFallbackOption(value: string) { + return { + value, + label: value, + keywords: buildBranchKeywords(value), + renderLabel: () => {value}, + }; +} + +export function BranchPolicyBranchPicker({ + label, + value, + onChange, + branches, + onRefresh, + loading, + testId, +}: { + label: string; + value: string; + onChange: (value: string) => void; + branches: Branch[]; + onRefresh?: () => void; + loading: boolean; + testId: string; +}) { + const { t } = useTranslation(); + const options = useMemo(() => { + const available = sortBranches(branches).map(branchToOption); + if (!value || available.some((option) => option.value === value)) return available; + return [selectedFallbackOption(value), ...available]; + }, [branches, value]); + + return ( + + ); +} + +export function BranchPolicyBranchField({ + label, + value, + onChange, + branches, + onRefresh, + loading, + helpLabel, + help, + testId, +}: { + label: string; + value: string; + onChange: (value: string) => void; + branches: Branch[]; + onRefresh?: () => void; + loading: boolean; + helpLabel: string; + help: string; + testId: string; +}) { + return ( +
+
+ + +
+ +
+ ); +} + +export function PolicyFields({ + draft, + setDraft, + branches, + refreshBranches, + branchesLoading, + idPrefix, +}: { + draft: PolicyDraft; + setDraft: (next: PolicyDraft) => void; + branches: Branch[]; + refreshBranches?: () => void; + branchesLoading: boolean; + idPrefix: string; +}) { + const { t } = useTranslation(); + const fieldId = (name: string) => `${idPrefix}-${name}`; + const update = (key: keyof PolicyDraft, value: string) => setDraft({ ...draft, [key]: value }); + return ( +
+
+
+ + +
+ update("name", event.target.value)} + maxLength={100} + autoFocus + /> +

{t("workspaces:branchPolicyNameHint")}

+
+
+ + update("description", event.target.value)} + maxLength={500} + /> +
+
+ update("base_branch", value)} + branches={branches} + onRefresh={refreshBranches} + loading={branchesLoading} + helpLabel={t("workspaces:branchPolicyBaseBranchHelpLabel")} + help={t("workspaces:branchPolicyBaseBranchHelp")} + testId={fieldId("base-picker")} + /> + update("pull_request_target", value)} + branches={branches} + onRefresh={refreshBranches} + loading={branchesLoading} + helpLabel={t("workspaces:branchPolicyPullRequestTargetHelpLabel")} + help={t("workspaces:branchPolicyPullRequestTargetHelp")} + testId={fieldId("target-picker")} + /> +
+
+
+ + +
+ update("branch_template", event.target.value)} + /> +

{t("workspaces:branchPolicyTemplateHint")}

+
+
+ ); +} diff --git a/apps/web/components/settings/repository-card.test.tsx b/apps/web/components/settings/repository-card.test.tsx index 8dde634c5a..7f53308d51 100644 --- a/apps/web/components/settings/repository-card.test.tsx +++ b/apps/web/components/settings/repository-card.test.tsx @@ -1,6 +1,7 @@ import { cleanup, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { i18n } from "@/lib/i18n"; +import { StateProvider } from "@/components/state-provider"; import { RepositoryCard } from "./repository-card"; import { SettingsSaveProvider } from "./settings-save-provider"; import { ToastProvider } from "@/components/toast-provider"; @@ -66,23 +67,26 @@ function script(id: string): RepositoryScript { function renderCard(repo: RepositoryWithScripts, autoOpen = false) { return render( - - - - - , + + + + + + + , ); } diff --git a/apps/web/components/settings/repository-card.tsx b/apps/web/components/settings/repository-card.tsx index e7e7a446cc..01c41c4dbd 100644 --- a/apps/web/components/settings/repository-card.tsx +++ b/apps/web/components/settings/repository-card.tsx @@ -21,6 +21,7 @@ import { RepositoryBranchTemplateHelp } from "@/components/settings/repository-b import { DeleteRepositoryDialog } from "@/components/settings/repository-delete-dialog"; import { CopyFilesField } from "@/components/settings/repository-copy-files-help"; import { RepositoryCustomScripts } from "@/components/settings/repository-custom-scripts"; +import { RepositoryBranchPolicies } from "@/components/settings/repository-branch-policies"; import { RepositorySecretBindings, validateRepositorySecretBindings, @@ -220,6 +221,7 @@ function RepositoryScriptFields({ type RepositoryEditViewProps = { repository: RepositoryWithScripts; + workspaceId: string; savedRepository?: RepositoryWithScripts; isDirty: boolean; areScriptsDirty: boolean; @@ -234,6 +236,7 @@ type RepositoryEditViewProps = { function RepositoryEditView({ repository, + workspaceId, savedRepository, isDirty, areScriptsDirty, @@ -283,6 +286,8 @@ function RepositoryEditView({ pullBeforeWorktree={repository.pull_before_worktree ?? true} /> + + ( ( diff --git a/apps/web/components/task-create-dialog-form-body.test.tsx b/apps/web/components/task-create-dialog-form-body.test.tsx index 3cd07c5859..8d2d690ad9 100644 --- a/apps/web/components/task-create-dialog-form-body.test.tsx +++ b/apps/web/components/task-create-dialog-form-body.test.tsx @@ -132,7 +132,9 @@ function makeFs(): DialogFormState { currentDefaults: { name: "", description: "" }, descriptionInputRef: createRef(), repositories: [], + repositoriesDirty: false, setRepositories: () => {}, + setRepositoriesDirty: () => {}, addRepository: () => {}, removeRepository: () => {}, updateRepository: () => {}, diff --git a/apps/web/components/task-create-dialog-form-reset.ts b/apps/web/components/task-create-dialog-form-reset.ts new file mode 100644 index 0000000000..ab02fb1f0e --- /dev/null +++ b/apps/web/components/task-create-dialog-form-reset.ts @@ -0,0 +1,72 @@ +import type { LocalRepository } from "@/lib/types/http"; +import type { + StepType, + TaskCreateDialogInitialValues, + TaskRemoteRepoRow, + TaskRepoRow, +} from "@/components/task-create-dialog-types"; + +export type FormResetters = { + setTaskName: (value: string) => void; + setHasTitle: (value: boolean) => void; + setHasDescription: (value: boolean) => void; + setHasPendingAttachmentUploads: (value: boolean) => void; + setRepositories: (value: TaskRepoRow[]) => void; + setRepositoriesDirty: (value: boolean) => void; + setRemoteRepos: (value: TaskRemoteRepoRow[]) => void; + setAgentProfileId: (value: string) => void; + setExecutorId: (value: string) => void; + setExecutorProfileId: (value: string) => void; + setSelectedWorkflowId: (value: string | null) => void; + setFetchedSteps: (value: StepType[] | null) => void; + setDiscoveredRepositories: (value: LocalRepository[]) => void; + setDiscoverReposLoaded: (value: boolean) => void; + setUseRemote: (value: boolean) => void; + setNoRepository: (value: boolean) => void; + setWorkspacePath: (value: string) => void; + setAutopilot: (value: boolean) => void; + setGitHubUrlError: (value: string | null) => void; + setFreshBranchEnabled: (value: boolean) => void; + setCurrentLocalBranch: (value: string) => void; + setBlockedBy: (value: string[]) => void; +}; + +export function resetTaskForm( + resetters: FormResetters, + name: string, + description: string, + workflowId: string | null, + initialValues?: TaskCreateDialogInitialValues, +) { + resetters.setTaskName(name); + resetters.setHasTitle(name.trim().length > 0); + resetters.setHasDescription(description.trim().length > 0); + resetters.setHasPendingAttachmentUploads(false); + const savedRepositories = initialValues?.repositories ?? []; + const restoredRepositories = savedRepositories.map((repository, index) => ({ + key: `row-${index}`, + repositoryId: repository.repository_id, + branch: + repository.branch_policy_base_branch ?? + repository.base_branch ?? + repository.checkout_branch ?? + "", + branchPolicyId: repository.branch_policy_id, + })); + if (restoredRepositories.length === 0 && initialValues?.repositoryId) { + restoredRepositories.push({ + key: "row-0", + repositoryId: initialValues.repositoryId, + branch: initialValues.branch ?? "", + branchPolicyId: undefined, + }); + } + resetters.setRepositories(restoredRepositories); + resetters.setRepositoriesDirty(false); + resetters.setAgentProfileId(""); + resetters.setExecutorId(""); + resetters.setExecutorProfileId(""); + resetters.setSelectedWorkflowId(workflowId); + resetters.setFetchedSteps(null); + resetters.setAutopilot(false); +} diff --git a/apps/web/components/task-create-dialog-handlers.ts b/apps/web/components/task-create-dialog-handlers.ts index 2ddce4dd37..48a205c9dd 100644 --- a/apps/web/components/task-create-dialog-handlers.ts +++ b/apps/web/components/task-create-dialog-handlers.ts @@ -265,8 +265,8 @@ function useRepositoryHandlers(fs: DialogFormState, repositories: Repository[]) const wasLocalPath = Boolean(fs.repositories.find((row) => row.key === key)?.localPath); const isLocalPath = !isWorkspaceRepo && Boolean(value); const patch: Partial = isWorkspaceRepo - ? { repositoryId: value, localPath: undefined, branch: "" } - : { repositoryId: undefined, localPath: value, branch: "" }; + ? { repositoryId: value, localPath: undefined, branch: "", branchPolicyId: undefined } + : { repositoryId: undefined, localPath: value, branch: "", branchPolicyId: undefined }; fs.updateRepository(key, patch); if (wasLocalPath !== isLocalPath) { fs.setExecutorId(""); @@ -286,13 +286,21 @@ function useRepositoryHandlers(fs: DialogFormState, repositories: Repository[]) const handleRowBranchChange = useCallback( (key: string, value: string) => { - fs.updateRepository(key, { branch: value }); + fs.updateRepository(key, { branch: value, branchPolicyId: undefined }); syncTaskCreateLastUsed({ branch: value }); }, [fs], ); - return { handleRowRepositoryChange, handleRowBranchChange }; + const handleRowPolicyChange = useCallback( + (key: string, policyId: string, baseBranch: string) => { + fs.updateRepository(key, { branch: baseBranch, branchPolicyId: policyId }); + syncTaskCreateLastUsed({ branch: baseBranch }); + }, + [fs], + ); + + return { handleRowRepositoryChange, handleRowBranchChange, handleRowPolicyChange }; } function useProfileAndNameHandlers(fs: DialogFormState) { diff --git a/apps/web/components/task-create-dialog-helpers.multi-repo.test.ts b/apps/web/components/task-create-dialog-helpers.multi-repo.test.ts index 3d69fa3499..cb1466d16c 100644 --- a/apps/web/components/task-create-dialog-helpers.multi-repo.test.ts +++ b/apps/web/components/task-create-dialog-helpers.multi-repo.test.ts @@ -3,6 +3,8 @@ import { buildRepositoriesPayload, findDuplicateRemoteRepo } from "./task-create import type { TaskRemoteRepoRow } from "@/components/task-create-dialog-types"; import type { PRInfo } from "@/hooks/domains/github/use-pr-info-by-url"; +const FRONT_REPOSITORY_ID = "repo-front"; + /** Minimal TaskRemoteRepoRow builder for the dedup tests. */ function remoteRow(key: string, url: string, branch = ""): TaskRemoteRepoRow { return { key, url, branch, source: "paste" }; @@ -23,7 +25,7 @@ describe("buildRepositoriesPayload — unified rows", () => { useRemote: false, remoteRepos: [], repositories: [ - { key: "r0", repositoryId: "repo-front", branch: "main" }, + { key: "r0", repositoryId: FRONT_REPOSITORY_ID, branch: "main" }, { key: "r1", repositoryId: "repo-back", branch: "develop" }, { key: "r2", branch: "" }, // no repo picked yet — dropped { key: "r3", repositoryId: "repo-shared", branch: "" }, @@ -31,12 +33,41 @@ describe("buildRepositoriesPayload — unified rows", () => { discoveredRepositories: [], }); expect(payload).toEqual([ - { repository_id: "repo-front", base_branch: "main", checkout_branch: undefined }, + { + repository_id: FRONT_REPOSITORY_ID, + base_branch: "main", + checkout_branch: undefined, + }, { repository_id: "repo-back", base_branch: "develop", checkout_branch: undefined }, { repository_id: "repo-shared", base_branch: undefined, checkout_branch: undefined }, ]); }); + it("submits a selected branch policy id without deriving identity from its label", () => { + const payload = buildRepositoriesPayload({ + useRemote: false, + remoteRepos: [], + repositories: [ + { + key: "r0", + repositoryId: FRONT_REPOSITORY_ID, + branch: "develop", + branchPolicyId: "policy-hotfix", + }, + ], + discoveredRepositories: [], + }); + + expect(payload).toEqual([ + { + repository_id: FRONT_REPOSITORY_ID, + base_branch: "develop", + checkout_branch: undefined, + branch_policy_id: "policy-hotfix", + }, + ]); + }); + it("emits local_path + default_branch for discovered (on-machine) rows", () => { const payload = buildRepositoriesPayload({ useRemote: false, diff --git a/apps/web/components/task-create-dialog-helpers.ts b/apps/web/components/task-create-dialog-helpers.ts index 2fea2e3d9c..af91055221 100644 --- a/apps/web/components/task-create-dialog-helpers.ts +++ b/apps/web/components/task-create-dialog-helpers.ts @@ -339,6 +339,7 @@ export function buildRepositoriesPayload(opts: { if (row.repositoryId) { return { repository_id: row.repositoryId, + ...(row.branchPolicyId ? { branch_policy_id: row.branchPolicyId } : {}), base_branch: branches.base_branch, checkout_branch: branches.checkout_branch, ...fresh, diff --git a/apps/web/components/task-create-dialog-locked-fields.ts b/apps/web/components/task-create-dialog-locked-fields.ts index bc9e3fda83..aace8f0725 100644 --- a/apps/web/components/task-create-dialog-locked-fields.ts +++ b/apps/web/components/task-create-dialog-locked-fields.ts @@ -31,6 +31,7 @@ export function useLockedFieldSync( if (lockedWorkflow && workflowId && workflowId !== fs.selectedWorkflowId) { fs.setSelectedWorkflowId(workflowId); } + if (initialValues?.repositories?.length) return; if (!repoId) return; const current = fs.repositories[0]; if (current?.repositoryId === repoId && current?.branch === (branch ?? "")) return; diff --git a/apps/web/components/task-create-dialog-pill.test.tsx b/apps/web/components/task-create-dialog-pill.test.tsx index e0238df303..ce4db3d35c 100644 --- a/apps/web/components/task-create-dialog-pill.test.tsx +++ b/apps/web/components/task-create-dialog-pill.test.tsx @@ -162,7 +162,45 @@ describe("sortBranches", () => { }); }); +describe("grouped pill options", () => { + it("keeps branch policies before branches even when a branch is selected", async () => { + render( +