From 7fd99c3f33e69bc79f88077c17498a453ff89f1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Tue, 21 Jul 2026 23:24:22 +0100 Subject: [PATCH] feat: complete GitLab integration parity (#1832) --- apps/backend/AGENTS.md | 1 + .../internal/agent/handlers/git_handlers.go | 22 +- .../agent/handlers/git_handlers_test.go | 38 + .../internal/agent/runtime/agentctl/git.go | 10 +- .../runtime/agentctl/git_partial_test.go | 26 + .../agent/runtime/lifecycle/container.go | 18 + .../runtime/lifecycle/executor_docker_test.go | 44 + .../executor_sprites_clone_auth_test.go | 19 + .../lifecycle/executor_sprites_helpers.go | 25 +- .../lifecycle/manager_execution_test.go | 16 + .../runtime/lifecycle/manager_startup.go | 1 + .../internal/agent/runtime/lifecycle/types.go | 15 + apps/backend/internal/agentctl/AGENTS.md | 5 +- .../internal/agentctl/server/process/git.go | 116 ++- .../server/process/git_pr_providers.go | 371 ++++++++- .../server/process/git_pr_providers_test.go | 332 ++++++++ .../agentctl/server/process/git_test.go | 40 +- .../agentctl/server/process/manager.go | 8 + .../process/manager_git_subpath_test.go | 22 + .../internal/backendapp/adapters_test.go | 1 + apps/backend/internal/backendapp/e2e_reset.go | 26 +- apps/backend/internal/backendapp/gateway.go | 80 +- .../gateway_git_association_test.go | 79 ++ apps/backend/internal/backendapp/helpers.go | 27 +- .../internal/backendapp/helpers_test.go | 45 +- apps/backend/internal/backendapp/main.go | 3 +- .../internal/backendapp/orchestrator.go | 15 +- apps/backend/internal/backendapp/services.go | 6 + .../internal/backendapp/turn_adapters.go | 33 + .../gateway/websocket/task_notifications.go | 1 + .../websocket/task_notifications_test.go | 3 +- .../internal/gitlab/action_presets_service.go | 26 +- .../internal/gitlab/action_presets_test.go | 22 + apps/backend/internal/gitlab/client.go | 15 + .../backend/internal/gitlab/client_helpers.go | 4 + .../internal/gitlab/client_helpers_test.go | 23 + apps/backend/internal/gitlab/constants.go | 16 +- apps/backend/internal/gitlab/controller.go | 68 +- .../internal/gitlab/controller_config.go | 145 ++++ .../internal/gitlab/controller_config_test.go | 121 +++ .../gitlab/controller_member_subscriptions.go | 170 ++++ .../controller_member_subscriptions_test.go | 237 ++++++ .../internal/gitlab/controller_task_mrs.go | 83 ++ .../gitlab/controller_task_mrs_test.go | 93 +++ .../internal/gitlab/controller_test.go | 30 + .../internal/gitlab/controller_watch_reset.go | 117 +++ .../gitlab/controller_watch_reset_test.go | 78 ++ .../gitlab/controller_watch_scope_test.go | 213 +++++ .../internal/gitlab/controller_watches.go | 225 +++--- apps/backend/internal/gitlab/copy.go | 59 ++ .../internal/gitlab/glab_client_test.go | 4 + apps/backend/internal/gitlab/goleak_test.go | 11 + apps/backend/internal/gitlab/handlers.go | 7 +- apps/backend/internal/gitlab/mock_client.go | 233 +++++- .../internal/gitlab/mock_client_test.go | 142 ++++ .../internal/gitlab/mock_controller.go | 237 +++++- .../internal/gitlab/mock_controller_test.go | 145 ++++ apps/backend/internal/gitlab/models.go | 48 ++ apps/backend/internal/gitlab/noop_client.go | 24 + .../internal/gitlab/noop_client_test.go | 6 + apps/backend/internal/gitlab/pat_client.go | 17 +- .../internal/gitlab/pat_client_actions.go | 88 ++ .../pat_client_member_subscriptions_test.go | 193 +++++ .../internal/gitlab/pat_client_test.go | 41 + apps/backend/internal/gitlab/poller.go | 37 +- .../internal/gitlab/poller_health_test.go | 50 ++ apps/backend/internal/gitlab/provider.go | 135 ++++ apps/backend/internal/gitlab/provider_test.go | 67 ++ .../gitlab/security_regression_test.go | 289 +++++++ apps/backend/internal/gitlab/service.go | 162 +++- .../internal/gitlab/service_cleanup.go | 58 ++ .../backend/internal/gitlab/service_config.go | 755 ++++++++++++++++++ .../internal/gitlab/service_config_test.go | 627 +++++++++++++++ .../backend/internal/gitlab/service_events.go | 8 + .../gitlab/service_issue_query_test.go | 67 ++ .../internal/gitlab/service_issue_watches.go | 126 ++- apps/backend/internal/gitlab/service_merge.go | 18 + .../internal/gitlab/service_reservations.go | 48 +- .../gitlab/service_review_query_test.go | 50 ++ .../backend/internal/gitlab/service_search.go | 12 + .../internal/gitlab/service_task_mr_link.go | 165 ++++ .../gitlab/service_task_mr_link_test.go | 273 +++++++ .../gitlab/service_watch_dependencies_test.go | 204 +++++ .../gitlab/service_watch_ownership_test.go | 325 ++++++++ .../internal/gitlab/service_watch_reset.go | 161 ++++ .../gitlab/service_watch_reset_test.go | 123 +++ .../internal/gitlab/service_watches.go | 191 ++++- apps/backend/internal/gitlab/store.go | 180 ++++- apps/backend/internal/gitlab/store_config.go | 153 ++++ .../internal/gitlab/store_config_test.go | 141 ++++ .../internal/gitlab/store_e2e_reset.go | 94 +++ .../internal/gitlab/store_e2e_reset_test.go | 122 +++ .../internal/gitlab/store_task_mr_link.go | 159 ++++ apps/backend/internal/gitlab/store_test.go | 65 +- .../internal/gitlab/store_watch_reset.go | 184 +++++ .../gitlab/store_watch_selfheal_test.go | 39 + apps/backend/internal/gitlab/store_watches.go | 403 ++++++++-- apps/backend/internal/gitlab/watch_models.go | 40 + .../backend/internal/improvekandev/handler.go | 10 +- .../internal/office/testharness/routes.go | 113 +++ .../office/testharness/routes_test.go | 85 ++ .../orchestrator/event_handlers_gitlab.go | 128 +-- .../event_handlers_gitlab_dispatch_test.go | 61 ++ .../orchestrator/executor/executor.go | 46 +- .../executor/executor_credentials.go | 94 ++- .../executor/executor_credentials_test.go | 100 +++ .../executor/executor_environment_test.go | 6 + .../orchestrator/executor/executor_execute.go | 28 +- .../executor/executor_interaction.go | 3 + .../executor/executor_interaction_test.go | 6 + .../executor/executor_mocks_test.go | 9 +- .../orchestrator/executor/executor_resume.go | 6 +- ...ecutor_resume_clone_default_branch_test.go | 6 +- .../orchestrator/executor/executor_test.go | 15 +- apps/backend/internal/orchestrator/service.go | 7 +- .../internal/orchestrator/source_gitlab.go | 290 +++++++ .../orchestrator/source_gitlab_test.go | 138 ++++ .../internal/orchestrator/watcher_dispatch.go | 10 + .../orchestrator/watcher_dispatch_test.go | 25 + apps/backend/internal/profiles/profiles.yaml | 4 + apps/backend/internal/repoclone/clone.go | 213 ++++- apps/backend/internal/repoclone/clone_test.go | 89 ++- apps/backend/internal/repoclone/protocol.go | 15 +- .../internal/repoclone/protocol_test.go | 2 +- apps/backend/internal/task/dto/dto.go | 2 + apps/backend/internal/task/dto/requests.go | 2 + .../task/handlers/process_handlers_test.go | 2 +- .../task/handlers/repository_handlers.go | 8 + apps/backend/internal/task/models/models.go | 1 + .../internal/task/repository/interface.go | 2 +- .../task/repository/sqlite/base_migrations.go | 6 + .../task/repository/sqlite/base_schema.go | 1 + .../repository/sqlite/repository_entity.go | 29 +- .../sqlite/repository_entity_test.go | 79 ++ .../task/service/remote_repository_test.go | 23 +- .../task/service/repository_discovery.go | 123 ++- .../task/service/repository_discovery_test.go | 23 + .../task/service/service_branches_qa_test.go | 4 +- .../task/service/service_branches_test.go | 2 +- .../internal/task/service/service_events.go | 1 + .../internal/task/service/service_requests.go | 3 + .../task/service/service_resources.go | 51 +- .../task/service/service_resources_test.go | 38 + .../internal/task/service/service_tasks.go | 53 +- .../internal/task/service/service_test.go | 25 + apps/web/app/actions/workspaces.ts | 2 + apps/web/app/gitlab/gitlab-page-client.tsx | 313 ++++++-- apps/web/app/gitlab/page.tsx | 50 +- .../workspace-repositories-client.tsx | 3 + .../workspace/workspace-repositories-dirty.ts | 1 + .../gitlab/action-presets-section.tsx | 205 +++++ .../gitlab/delete-watch-dialog.test.tsx | 42 + .../components/gitlab/delete-watch-dialog.tsx | 73 ++ .../gitlab/gitlab-credentials-save.test.tsx | 46 ++ .../gitlab/gitlab-settings.test.tsx | 309 ++++++- .../web/components/gitlab/gitlab-settings.tsx | 461 +++++++---- .../gitlab/issue-watch-dialog.test.tsx | 42 + .../components/gitlab/issue-watch-dialog.tsx | 28 + .../gitlab/issue-watch-placeholders.ts | 7 + .../gitlab/issue-watch-table.test.tsx | 62 ++ .../components/gitlab/issue-watch-table.tsx | 8 + .../components/gitlab/mr-commits-section.tsx | 30 + .../components/gitlab/mr-detail-panel.test.ts | 62 ++ .../web/components/gitlab/mr-detail-panel.tsx | 502 ++++++++++++ .../gitlab/mr-discussions-section.test.ts | 51 ++ .../gitlab/mr-discussions-section.tsx | 178 +++++ .../components/gitlab/mr-files-section.tsx | 35 + .../gitlab/mr-overview-section.test.ts | 20 + .../components/gitlab/mr-overview-section.tsx | 104 +++ .../gitlab/mr-reviewer-control.test.ts | 58 ++ .../components/gitlab/mr-reviewer-control.tsx | 193 +++++ .../gitlab/mr-topbar-button.test.ts | 42 + .../components/gitlab/mr-topbar-button.tsx | 309 ++++++- .../gitlab/my-gitlab/issue-list.tsx | 59 +- .../components/gitlab/my-gitlab/mr-list.tsx | 47 +- .../my-gitlab/mr-row-task-indicator.test.tsx | 74 ++ .../my-gitlab/mr-row-task-indicator.tsx | 18 + .../my-gitlab/quick-task-launcher.test.tsx | 243 ++++++ .../gitlab/my-gitlab/quick-task-launcher.tsx | 166 ++++ .../gitlab/my-gitlab/start-task-menu.tsx | 55 ++ .../gitlab/my-gitlab/task-presets.ts | 16 + .../my-gitlab/use-gitlab-search.test.ts | 80 +- .../gitlab/my-gitlab/use-gitlab-search.ts | 68 +- .../gitlab/review-watch-dialog.test.tsx | 48 ++ .../components/gitlab/review-watch-dialog.tsx | 28 + .../gitlab/review-watch-placeholders.ts | 7 + .../gitlab/review-watch-table.test.tsx | 53 ++ .../components/gitlab/review-watch-table.tsx | 8 + .../gitlab/subscription-toggle.test.ts | 85 ++ .../components/gitlab/subscription-toggle.tsx | 141 ++++ .../gitlab/task-mr-link-dialog.test.tsx | 64 ++ .../components/gitlab/task-mr-link-dialog.tsx | 196 +++++ apps/web/components/gitlab/watch-dialog.tsx | 405 ++++++++++ apps/web/components/gitlab/watch-form.test.ts | 80 ++ apps/web/components/gitlab/watch-form.ts | 150 ++++ apps/web/components/gitlab/watch-settings.tsx | 452 +++++++++++ apps/web/components/gitlab/watch-table.tsx | 255 ++++++ .../integration-copy-config.test.ts | 13 +- .../integrations/integration-copy-config.ts | 14 +- .../web/components/kanban-card-store.test.tsx | 40 + apps/web/components/kanban-card.tsx | 6 +- .../web/components/task/chat-context-items.ts | 2 +- .../task/dockview-add-panel-items.tsx | 17 + .../task/dockview-auto-mr-panel.test.ts | 80 ++ .../components/task/dockview-auto-mr-panel.ts | 123 +++ .../task/dockview-desktop-layout.tsx | 5 +- .../task/dockview-header-actions.tsx | 3 + .../task/dockview-panel-content.tsx | 12 +- .../dockview-session-tab-activation.test.ts | 97 +++ .../task/dockview-session-tab-activation.ts | 41 + .../task/dockview-session-tabs.test.ts | 94 --- .../components/task/dockview-session-tabs.ts | 132 ++- apps/web/components/task/dockview-shared.tsx | 15 +- .../components/task/dockview-watermark.tsx | 5 +- .../mobile/session-mobile-bottom-nav.test.tsx | 22 + .../task/mobile/session-mobile-bottom-nav.tsx | 10 +- .../task/mobile/session-mobile-layout.tsx | 94 ++- .../session-mobile-top-bar-dialog-parts.tsx | 14 +- .../session-mobile-top-bar-git-controls.tsx | 102 ++- .../task/mobile/session-mobile-top-bar.tsx | 226 +++--- .../components/task/review-detail-panel.tsx | 38 + .../task/review-panel-provider.test.ts | 18 + .../components/task/review-panel-provider.ts | 13 + .../web/components/task/task-center-panel.tsx | 36 +- apps/web/components/task/task-pr-open.test.ts | 19 +- apps/web/components/task/task-pr-open.ts | 13 + .../components/task/task-pr-picker-dialog.tsx | 33 +- apps/web/components/task/task-pr-shortcut.tsx | 12 +- .../vcs/change-request-feedback.test.ts | 42 + .../components/vcs/change-request-feedback.ts | 23 + .../vcs/vcs-change-request-dialog.test.tsx | 43 + .../vcs/vcs-change-request-dialog.tsx | 116 +++ .../components/vcs/vcs-dialog-fields.test.tsx | 75 ++ apps/web/components/vcs/vcs-dialog-fields.tsx | 35 +- apps/web/components/vcs/vcs-dialogs.tsx | 187 ++--- .../components/watcher-repository-fields.tsx | 8 +- .../watches/reset-watch-dialog.test.tsx | 70 ++ .../components/watches/reset-watch-dialog.tsx | 37 +- apps/web/e2e/fixtures/backend.ts | 13 + apps/web/e2e/fixtures/test-base.ts | 11 +- apps/web/e2e/helpers/api-client.ts | 247 ++++++ apps/web/e2e/helpers/gitlab.ts | 110 +++ apps/web/e2e/pages/gitlab-page.ts | 50 ++ apps/web/e2e/pages/gitlab-settings-page.ts | 35 + .../tests/gitlab/gitlab-mr-creation.spec.ts | 93 +++ .../e2e/tests/gitlab/gitlab-parity.spec.ts | 131 +++ .../e2e/tests/gitlab/gitlab-watches.spec.ts | 159 ++++ .../tests/gitlab/mobile-gitlab-parity.spec.ts | 216 +++++ .../layout/compact-desktop-responsive.spec.ts | 2 +- .../task/create-task-remote-repo.spec.ts | 8 + .../mobile-create-task-remote-repo.spec.ts | 19 +- .../github/use-branches-by-url.test.ts | 113 ++- .../domains/github/use-branches-by-url.ts | 76 +- .../domains/gitlab/request-identity.test.ts | 10 + .../hooks/domains/gitlab/request-identity.ts | 8 + .../gitlab/use-gitlab-issue-watches.test.ts | 158 ++++ .../gitlab/use-gitlab-issue-watches.ts | 104 ++- .../gitlab/use-gitlab-review-watches.test.ts | 160 ++++ .../gitlab/use-gitlab-review-watches.ts | 110 ++- .../hooks/domains/gitlab/use-gitlab-stats.ts | 13 +- .../domains/gitlab/use-gitlab-status.test.ts | 161 ++++ .../hooks/domains/gitlab/use-gitlab-status.ts | 77 +- .../hooks/domains/gitlab/use-mr-actions.ts | 33 + .../domains/gitlab/use-mr-feedback.test.ts | 103 +++ .../hooks/domains/gitlab/use-mr-feedback.ts | 150 ++++ .../gitlab/use-mr-key-to-tasks.test.ts | 119 +++ .../domains/gitlab/use-mr-key-to-tasks.ts | 31 + .../hooks/domains/gitlab/use-task-mr.test.ts | 130 ++- apps/web/hooks/domains/gitlab/use-task-mr.ts | 18 +- .../integrations/use-remote-repositories.ts | 5 +- apps/web/hooks/use-git-operations.test.ts | 18 + apps/web/hooks/use-git-operations.ts | 38 + apps/web/lib/api/domains/gitlab-api.test.ts | 226 +++++- apps/web/lib/api/domains/gitlab-api.ts | 516 ++++++++---- apps/web/lib/gitlab-identity.ts | 17 + .../state/dockview-env-scoped-components.ts | 1 + .../lib/state/dockview-panel-actions.test.ts | 42 + apps/web/lib/state/dockview-panel-actions.ts | 53 +- apps/web/lib/state/dockview-store.ts | 2 + .../web/lib/state/layout-manager/constants.ts | 3 + apps/web/lib/state/layout-manager/merger.ts | 1 + .../lib/state/slices/comments/format.test.ts | 9 + apps/web/lib/state/slices/comments/format.ts | 3 +- apps/web/lib/state/slices/comments/types.ts | 3 + .../state/slices/gitlab/gitlab-slice.test.ts | 96 ++- .../lib/state/slices/gitlab/gitlab-slice.ts | 49 +- apps/web/lib/state/slices/gitlab/types.ts | 16 +- apps/web/lib/state/slices/ui/types.ts | 4 +- apps/web/lib/state/slices/ui/ui-slice.test.ts | 15 + apps/web/lib/state/slices/ui/ui-slice.ts | 18 +- apps/web/lib/state/store.ts | 9 +- apps/web/lib/types/backend.ts | 5 + apps/web/lib/types/gitlab.ts | 59 +- apps/web/lib/types/http.ts | 1 + apps/web/lib/ws/handlers/gitlab.test.ts | 55 ++ apps/web/lib/ws/handlers/gitlab.ts | 14 + apps/web/lib/ws/router.ts | 2 + apps/web/src/spa-routes.tsx | 9 +- ...-20-repository-provider-origin-identity.md | 45 ++ docs/decisions/INDEX.md | 1 + docs/plans/gitlab-integration-parity/plan.md | 237 ++++++ .../task-01-workspace-connection.md | 91 +++ .../task-02-watcher-dispatch.md | 76 ++ .../task-03-task-mr-linking-launch.md | 83 ++ .../task-04-reviewers-subscriptions.md | 69 ++ .../task-05-watch-settings-ui.md | 72 ++ .../task-06-mr-review-ui.md | 84 ++ .../task-07-mr-creation-runtime.md | 84 ++ .../task-08-e2e-docs-verification.md | 83 ++ docs/public/extending-kandev.md | 2 +- docs/public/feature-status.md | 2 +- docs/public/git-operations.md | 19 +- docs/public/integrations.md | 69 +- docs/public/remote-cloud-environment.md | 2 +- docs/public/sessions-and-review.md | 13 +- docs/public/websocket-api.md | 2 + docs/specs/INDEX.md | 2 +- docs/specs/gitlab-integration/spec.md | 447 ++++++++--- 318 files changed, 23362 insertions(+), 2004 deletions(-) create mode 100644 apps/backend/internal/agent/runtime/agentctl/git_partial_test.go create mode 100644 apps/backend/internal/agentctl/server/process/git_pr_providers_test.go create mode 100644 apps/backend/internal/backendapp/gateway_git_association_test.go create mode 100644 apps/backend/internal/gitlab/controller_config.go create mode 100644 apps/backend/internal/gitlab/controller_config_test.go create mode 100644 apps/backend/internal/gitlab/controller_member_subscriptions.go create mode 100644 apps/backend/internal/gitlab/controller_member_subscriptions_test.go create mode 100644 apps/backend/internal/gitlab/controller_task_mrs.go create mode 100644 apps/backend/internal/gitlab/controller_task_mrs_test.go create mode 100644 apps/backend/internal/gitlab/controller_watch_reset.go create mode 100644 apps/backend/internal/gitlab/controller_watch_reset_test.go create mode 100644 apps/backend/internal/gitlab/controller_watch_scope_test.go create mode 100644 apps/backend/internal/gitlab/copy.go create mode 100644 apps/backend/internal/gitlab/goleak_test.go create mode 100644 apps/backend/internal/gitlab/mock_controller_test.go create mode 100644 apps/backend/internal/gitlab/pat_client_member_subscriptions_test.go create mode 100644 apps/backend/internal/gitlab/poller_health_test.go create mode 100644 apps/backend/internal/gitlab/security_regression_test.go create mode 100644 apps/backend/internal/gitlab/service_config.go create mode 100644 apps/backend/internal/gitlab/service_config_test.go create mode 100644 apps/backend/internal/gitlab/service_issue_query_test.go create mode 100644 apps/backend/internal/gitlab/service_review_query_test.go create mode 100644 apps/backend/internal/gitlab/service_task_mr_link.go create mode 100644 apps/backend/internal/gitlab/service_task_mr_link_test.go create mode 100644 apps/backend/internal/gitlab/service_watch_dependencies_test.go create mode 100644 apps/backend/internal/gitlab/service_watch_ownership_test.go create mode 100644 apps/backend/internal/gitlab/service_watch_reset.go create mode 100644 apps/backend/internal/gitlab/service_watch_reset_test.go create mode 100644 apps/backend/internal/gitlab/store_config.go create mode 100644 apps/backend/internal/gitlab/store_config_test.go create mode 100644 apps/backend/internal/gitlab/store_e2e_reset.go create mode 100644 apps/backend/internal/gitlab/store_e2e_reset_test.go create mode 100644 apps/backend/internal/gitlab/store_task_mr_link.go create mode 100644 apps/backend/internal/gitlab/store_watch_reset.go create mode 100644 apps/backend/internal/gitlab/store_watch_selfheal_test.go create mode 100644 apps/backend/internal/orchestrator/event_handlers_gitlab_dispatch_test.go create mode 100644 apps/backend/internal/orchestrator/source_gitlab.go create mode 100644 apps/backend/internal/orchestrator/source_gitlab_test.go create mode 100644 apps/web/components/gitlab/action-presets-section.tsx create mode 100644 apps/web/components/gitlab/delete-watch-dialog.test.tsx create mode 100644 apps/web/components/gitlab/delete-watch-dialog.tsx create mode 100644 apps/web/components/gitlab/gitlab-credentials-save.test.tsx create mode 100644 apps/web/components/gitlab/issue-watch-dialog.test.tsx create mode 100644 apps/web/components/gitlab/issue-watch-dialog.tsx create mode 100644 apps/web/components/gitlab/issue-watch-placeholders.ts create mode 100644 apps/web/components/gitlab/issue-watch-table.test.tsx create mode 100644 apps/web/components/gitlab/issue-watch-table.tsx create mode 100644 apps/web/components/gitlab/mr-commits-section.tsx create mode 100644 apps/web/components/gitlab/mr-detail-panel.test.ts create mode 100644 apps/web/components/gitlab/mr-detail-panel.tsx create mode 100644 apps/web/components/gitlab/mr-discussions-section.test.ts create mode 100644 apps/web/components/gitlab/mr-discussions-section.tsx create mode 100644 apps/web/components/gitlab/mr-files-section.tsx create mode 100644 apps/web/components/gitlab/mr-overview-section.test.ts create mode 100644 apps/web/components/gitlab/mr-overview-section.tsx create mode 100644 apps/web/components/gitlab/mr-reviewer-control.test.ts create mode 100644 apps/web/components/gitlab/mr-reviewer-control.tsx create mode 100644 apps/web/components/gitlab/mr-topbar-button.test.ts create mode 100644 apps/web/components/gitlab/my-gitlab/mr-row-task-indicator.test.tsx create mode 100644 apps/web/components/gitlab/my-gitlab/mr-row-task-indicator.tsx create mode 100644 apps/web/components/gitlab/my-gitlab/quick-task-launcher.test.tsx create mode 100644 apps/web/components/gitlab/my-gitlab/quick-task-launcher.tsx create mode 100644 apps/web/components/gitlab/my-gitlab/start-task-menu.tsx create mode 100644 apps/web/components/gitlab/my-gitlab/task-presets.ts create mode 100644 apps/web/components/gitlab/review-watch-dialog.test.tsx create mode 100644 apps/web/components/gitlab/review-watch-dialog.tsx create mode 100644 apps/web/components/gitlab/review-watch-placeholders.ts create mode 100644 apps/web/components/gitlab/review-watch-table.test.tsx create mode 100644 apps/web/components/gitlab/review-watch-table.tsx create mode 100644 apps/web/components/gitlab/subscription-toggle.test.ts create mode 100644 apps/web/components/gitlab/subscription-toggle.tsx create mode 100644 apps/web/components/gitlab/task-mr-link-dialog.test.tsx create mode 100644 apps/web/components/gitlab/task-mr-link-dialog.tsx create mode 100644 apps/web/components/gitlab/watch-dialog.tsx create mode 100644 apps/web/components/gitlab/watch-form.test.ts create mode 100644 apps/web/components/gitlab/watch-form.ts create mode 100644 apps/web/components/gitlab/watch-settings.tsx create mode 100644 apps/web/components/gitlab/watch-table.tsx create mode 100644 apps/web/components/kanban-card-store.test.tsx create mode 100644 apps/web/components/task/dockview-auto-mr-panel.test.ts create mode 100644 apps/web/components/task/dockview-auto-mr-panel.ts create mode 100644 apps/web/components/task/dockview-session-tab-activation.test.ts create mode 100644 apps/web/components/task/dockview-session-tab-activation.ts create mode 100644 apps/web/components/task/mobile/session-mobile-bottom-nav.test.tsx create mode 100644 apps/web/components/task/review-detail-panel.tsx create mode 100644 apps/web/components/task/review-panel-provider.test.ts create mode 100644 apps/web/components/task/review-panel-provider.ts create mode 100644 apps/web/components/vcs/change-request-feedback.test.ts create mode 100644 apps/web/components/vcs/change-request-feedback.ts create mode 100644 apps/web/components/vcs/vcs-change-request-dialog.test.tsx create mode 100644 apps/web/components/vcs/vcs-change-request-dialog.tsx create mode 100644 apps/web/components/vcs/vcs-dialog-fields.test.tsx create mode 100644 apps/web/components/watches/reset-watch-dialog.test.tsx create mode 100644 apps/web/e2e/helpers/gitlab.ts create mode 100644 apps/web/e2e/pages/gitlab-page.ts create mode 100644 apps/web/e2e/pages/gitlab-settings-page.ts create mode 100644 apps/web/e2e/tests/gitlab/gitlab-mr-creation.spec.ts create mode 100644 apps/web/e2e/tests/gitlab/gitlab-parity.spec.ts create mode 100644 apps/web/e2e/tests/gitlab/gitlab-watches.spec.ts create mode 100644 apps/web/e2e/tests/gitlab/mobile-gitlab-parity.spec.ts create mode 100644 apps/web/hooks/domains/gitlab/request-identity.test.ts create mode 100644 apps/web/hooks/domains/gitlab/request-identity.ts create mode 100644 apps/web/hooks/domains/gitlab/use-gitlab-issue-watches.test.ts create mode 100644 apps/web/hooks/domains/gitlab/use-gitlab-review-watches.test.ts create mode 100644 apps/web/hooks/domains/gitlab/use-gitlab-status.test.ts create mode 100644 apps/web/hooks/domains/gitlab/use-mr-actions.ts create mode 100644 apps/web/hooks/domains/gitlab/use-mr-feedback.test.ts create mode 100644 apps/web/hooks/domains/gitlab/use-mr-feedback.ts create mode 100644 apps/web/hooks/domains/gitlab/use-mr-key-to-tasks.test.ts create mode 100644 apps/web/hooks/domains/gitlab/use-mr-key-to-tasks.ts create mode 100644 apps/web/hooks/use-git-operations.test.ts create mode 100644 apps/web/lib/gitlab-identity.ts create mode 100644 apps/web/lib/ws/handlers/gitlab.test.ts create mode 100644 apps/web/lib/ws/handlers/gitlab.ts create mode 100644 docs/decisions/2026-07-20-repository-provider-origin-identity.md create mode 100644 docs/plans/gitlab-integration-parity/plan.md create mode 100644 docs/plans/gitlab-integration-parity/task-01-workspace-connection.md create mode 100644 docs/plans/gitlab-integration-parity/task-02-watcher-dispatch.md create mode 100644 docs/plans/gitlab-integration-parity/task-03-task-mr-linking-launch.md create mode 100644 docs/plans/gitlab-integration-parity/task-04-reviewers-subscriptions.md create mode 100644 docs/plans/gitlab-integration-parity/task-05-watch-settings-ui.md create mode 100644 docs/plans/gitlab-integration-parity/task-06-mr-review-ui.md create mode 100644 docs/plans/gitlab-integration-parity/task-07-mr-creation-runtime.md create mode 100644 docs/plans/gitlab-integration-parity/task-08-e2e-docs-verification.md diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index d7489ed9a2..4fb69a8f10 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -189,6 +189,7 @@ Client (WS) ← Orchestrator ← Lifecycle Manager ←──── stream update - Provider pattern for DI; stderr for logs, stdout for ACP only. - Pass context through chains; event bus for cross-component comm. +- **Repository provider identity:** Provider-backed repositories are keyed by workspace, provider, normalized `provider_host` origin, full owner/namespace, and name. Persist `provider_host` when importing or resolving a remote; do not infer self-managed GitLab rows from owner/name alone. Legacy rows with an empty host have unknown identity and must fail closed for provider write/link operations. - **Execution access:** Workspace-oriented handlers (files, shell, inference, ports, vscode, LSP) MUST use `GetOrEnsureExecution(ctx, sessionID)` — it recovers from backend restarts by creating executions on-demand. Only use `GetExecutionBySessionID` for operations that require a running agent process (prompt, cancel, mode). - **Task lifecycle events:** Any code path that mutates a task row must publish via the event bus (`task.created` / `task.updated` / `task.deleted`) — either by going through `Service.CreateTask` / `UpdateTask` / `DeleteTask` / `ArchiveTask`, or by calling `publishTaskEvent` (or one of the `Publish*` helpers in `service_events.go`) directly. Walking `repository.TaskRepository` straight bypasses event publishing and breaks WS-driven UI like the All-Workflows kanban view. `HandoffService`'s cascade methods learned this the hard way — they now require a `TaskEventPublisher` wired via `SetTaskEventPublisher`. New cascade / bulk / cleanup paths must follow the same pattern. - **Testing:** Prefer `testing/synctest` (Go 1.24+) over `time.Sleep` for time-dependent tests. Use `synctest.Test` to wrap tests with tickers or timeouts — it advances fake time instantly when all goroutines are idle. When `synctest` is not feasible (e.g., tests spawning external processes like `git`), use channel-based synchronization (`<-started`, non-blocking `select`) instead of sleep-based waits. Reserve `time.Sleep` only for integration tests that need real subprocess execution time. diff --git a/apps/backend/internal/agent/handlers/git_handlers.go b/apps/backend/internal/agent/handlers/git_handlers.go index b629c3b6f4..98ab97aad9 100644 --- a/apps/backend/internal/agent/handlers/git_handlers.go +++ b/apps/backend/internal/agent/handlers/git_handlers.go @@ -32,7 +32,7 @@ const singleflightComputeTimeout = 60 * time.Second // The callback uses it to scope the resulting TaskPR / PRWatch rows to the // owning repository so the second repo's PR doesn't overwrite the first. // Parameters: ctx, sessionID, taskID, prURL, branch. -type PRCreatedCallback func(ctx context.Context, sessionID, taskID, prURL, branch, repo string) +type PRCreatedCallback func(ctx context.Context, sessionID, taskID, provider, prURL, branch, repo string) // GitOperationFailedCallback is called when a git operation fails. // Parameters: ctx, sessionID, taskID, operation name, error output. @@ -105,6 +105,17 @@ func isGitHubPRURL(prURL string) bool { return strings.Contains(strings.ToLower(strings.TrimSpace(prURL)), "/pull/") } +func shouldAssociateCreatedChange(provider, changeURL string) bool { + switch strings.ToLower(strings.TrimSpace(provider)) { + case "github": + return isGitHubPRURL(changeURL) + case "gitlab": + return strings.Contains(strings.ToLower(strings.TrimSpace(changeURL)), "/-/merge_requests/") + default: + return provider == "" && isGitHubPRURL(changeURL) + } +} + // SetOnGitOperationFailed sets a callback invoked when a git operation fails. func (h *GitHandlers) SetOnGitOperationFailed(cb GitOperationFailedCallback) { h.onGitOperationFailed = cb @@ -602,21 +613,26 @@ func (h *GitHandlers) wsCreatePR(ctx context.Context, msg *ws.Message) (*ws.Mess // flows through so the orchestrator can scope the resulting TaskPR / // PRWatch rows to the per-task repository_id. // Use a timeout-bound context so a stuck callback doesn't leak the goroutine. - if result.Success && result.PRURL != "" && h.onPRCreated != nil && isGitHubPRURL(result.PRURL) { + if result.Success && result.PRURL != "" && h.onPRCreated != nil && shouldAssociateCreatedChange(result.Provider, result.PRURL) { execution, ok := h.lifecycleMgr.GetExecutionBySessionID(req.SessionID) if ok && execution.TaskID != "" { sessionID := req.SessionID taskID := execution.TaskID prURL := result.PRURL + provider := result.Provider repo := req.Repo go func() { callbackCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - h.onPRCreated(callbackCtx, sessionID, taskID, prURL, "", repo) + h.onPRCreated(callbackCtx, sessionID, taskID, provider, prURL, "", repo) }() } } + return newCreatePRResponse(msg, result) +} + +func newCreatePRResponse(msg *ws.Message, result *client.PRCreateResult) (*ws.Message, error) { return ws.NewResponse(msg.ID, msg.Action, result) } diff --git a/apps/backend/internal/agent/handlers/git_handlers_test.go b/apps/backend/internal/agent/handlers/git_handlers_test.go index 3add1f15b6..cdcca07d6b 100644 --- a/apps/backend/internal/agent/handlers/git_handlers_test.go +++ b/apps/backend/internal/agent/handlers/git_handlers_test.go @@ -182,6 +182,44 @@ func TestIsGitHubPRURL(t *testing.T) { } } +func TestShouldAssociateCreatedChange(t *testing.T) { + tests := []struct { + provider string + url string + want bool + }{ + {provider: "github", url: "https://github.com/acme/widgets/pull/42", want: true}, + {provider: "gitlab", url: "https://gitlab.example/acme/widgets/-/merge_requests/42", want: true}, + {provider: "azure_repos", url: "https://dev.azure.com/acme/project/_git/widgets/pullrequest/42", want: false}, + {provider: "gitlab", url: "https://github.com/acme/widgets/pull/42", want: false}, + } + for _, tt := range tests { + if got := shouldAssociateCreatedChange(tt.provider, tt.url); got != tt.want { + t.Errorf("shouldAssociateCreatedChange(%q, %q) = %v, want %v", tt.provider, tt.url, got, tt.want) + } + } +} + +func TestCreatePRResponsePreservesBranchPushedPartialState(t *testing.T) { + request := &ws.Message{ID: "request-1", Action: ws.ActionWorktreeCreatePR} + response, err := newCreatePRResponse(request, &client.PRCreateResult{ + Provider: "gitlab", + BranchPushed: true, + Error: "branch was pushed; retry merge request creation", + }) + if err != nil { + t.Fatalf("newCreatePRResponse: %v", err) + } + var decoded client.PRCreateResult + if err := json.Unmarshal(response.Payload, &decoded); err != nil { + t.Fatalf("decode response: %v", err) + } + if decoded.Success || !decoded.BranchPushed || decoded.Provider != "gitlab" || + decoded.Error != "branch was pushed; retry merge request creation" { + t.Fatalf("decoded response = %+v", decoded) + } +} + func TestNotifyGitOperationFailed_NilResult(t *testing.T) { log := newTestLogger() called := false diff --git a/apps/backend/internal/agent/runtime/agentctl/git.go b/apps/backend/internal/agent/runtime/agentctl/git.go index acb540cb23..94c3dbb25b 100644 --- a/apps/backend/internal/agent/runtime/agentctl/git.go +++ b/apps/backend/internal/agent/runtime/agentctl/git.go @@ -22,10 +22,12 @@ type GitOperationResult struct { // PRCreateResult represents the result of a PR creation operation. // This matches the server-side process.PRCreateResult. type PRCreateResult struct { - Success bool `json:"success"` - PRURL string `json:"pr_url,omitempty"` - Output string `json:"output,omitempty"` - Error string `json:"error,omitempty"` + Success bool `json:"success"` + BranchPushed bool `json:"branch_pushed,omitempty"` + PRURL string `json:"pr_url,omitempty"` + Provider string `json:"provider,omitempty"` + Output string `json:"output,omitempty"` + Error string `json:"error,omitempty"` } // GitPull performs a git pull operation on the worktree. diff --git a/apps/backend/internal/agent/runtime/agentctl/git_partial_test.go b/apps/backend/internal/agent/runtime/agentctl/git_partial_test.go new file mode 100644 index 0000000000..c7b6bdc325 --- /dev/null +++ b/apps/backend/internal/agent/runtime/agentctl/git_partial_test.go @@ -0,0 +1,26 @@ +package client + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGitCreatePRDecodesBranchPushedPartialState(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":false,"branch_pushed":true,"provider":"gitlab","error":"branch was pushed; retry merge request creation"}`)) + })) + t.Cleanup(server.Close) + client := &Client{baseURL: server.URL, httpClient: server.Client()} + + result, err := client.GitCreatePR(context.Background(), "Title", "Body", "main", false, "") + if err != nil { + t.Fatalf("GitCreatePR: %v", err) + } + if result.Success || !result.BranchPushed || result.Provider != "gitlab" || + result.Error != "branch was pushed; retry merge request creation" { + t.Fatalf("result = %+v", result) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/container.go b/apps/backend/internal/agent/runtime/lifecycle/container.go index 3c74ebd1da..be72a0cd3c 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/container.go +++ b/apps/backend/internal/agent/runtime/lifecycle/container.go @@ -8,6 +8,7 @@ import ( "encoding/hex" "fmt" "os" + "strconv" "strings" "time" @@ -643,10 +644,27 @@ func (cm *ContainerManager) buildEnvVars(config ContainerConfig) []string { ) gitConfigCount = 4 } + if incomingCount, err := strconv.Atoi(config.Credentials["GIT_CONFIG_COUNT"]); err == nil { + for i := 0; i < incomingCount; i++ { + key, keyOK := config.Credentials[fmt.Sprintf("GIT_CONFIG_KEY_%d", i)] + value, valueOK := config.Credentials[fmt.Sprintf("GIT_CONFIG_VALUE_%d", i)] + if !keyOK || !valueOK { + continue + } + env = append(env, + fmt.Sprintf("GIT_CONFIG_KEY_%d=%s", gitConfigCount, key), + fmt.Sprintf("GIT_CONFIG_VALUE_%d=%s", gitConfigCount, value), + ) + gitConfigCount++ + } + } env = append(env, fmt.Sprintf("GIT_CONFIG_COUNT=%d", gitConfigCount)) // Inject credentials from the provided credentials map for k, v := range config.Credentials { + if k == "GIT_CONFIG_COUNT" || strings.HasPrefix(k, "GIT_CONFIG_KEY_") || strings.HasPrefix(k, "GIT_CONFIG_VALUE_") { + continue + } env = append(env, fmt.Sprintf("%s=%s", k, v)) } diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_docker_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_docker_test.go index fa9eb20c4b..94eac7504e 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_docker_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_docker_test.go @@ -718,6 +718,50 @@ func TestResolvePrepareScript(t *testing.T) { t.Fatal("expected non-empty default script") } }) + + t.Run("GitLab clone URL remains credential free", func(t *testing.T) { + req := &ExecutorCreateRequest{ + Metadata: map[string]interface{}{ + "repository_clone_url": "http://gitlab.internal:8080/group/repo.git", + "base_branch": "main", + "worktree_branch": "feature/gitlab", + }, + Env: map[string]string{ + "GITLAB_TOKEN": "glpat-super-secret", + "KANDEV_GITLAB_HOST": "http://gitlab.internal:8080", + }, + } + script := exec.resolvePrepareScript(req) + if !strings.Contains(script, "'http://gitlab.internal:8080/group/repo.git'") { + t.Fatalf("resolved script does not retain raw clone URL:\n%s", script) + } + if strings.Contains(script, "glpat-super-secret") || strings.Contains(script, "oauth2@") { + t.Fatalf("resolved script contains GitLab credentials:\n%s", script) + } + }) +} + +func TestContainerBuildEnvVarsMergesEphemeralGitLabCredentialHelper(t *testing.T) { + credentials := map[string]string{ + "GITLAB_TOKEN": "glpat-super-secret", + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "credential.http://gitlab.internal:8080.helper", + "GIT_CONFIG_VALUE_0": `!f() { echo "username=oauth2"; echo "password=$GITLAB_TOKEN"; }; f`, + } + env := (&ContainerManager{}).buildEnvVars(ContainerConfig{ + AgentConfig: agents.NewMockAgent(), + Credentials: credentials, + }) + joined := strings.Join(env, "\n") + if !strings.Contains(joined, "GIT_CONFIG_COUNT=4") || !strings.Contains(joined, "GIT_CONFIG_KEY_3=credential.http://gitlab.internal:8080.helper") { + t.Fatalf("GitLab credential helper was not merged:\n%s", joined) + } + if strings.Count(joined, "GIT_CONFIG_COUNT=") != 1 { + t.Fatalf("duplicate GIT_CONFIG_COUNT:\n%s", joined) + } + if strings.Contains(joined, "password=glpat-super-secret") { + t.Fatalf("token embedded in credential helper:\n%s", joined) + } } func TestLocalPathFromCloneURL(t *testing.T) { diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_clone_auth_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_clone_auth_test.go index cf2acf50e1..d52f9ed1e5 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_clone_auth_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_clone_auth_test.go @@ -51,6 +51,25 @@ func TestInjectTokenIntoURL_HonoursGHTokenFallback(t *testing.T) { require.Equal(t, "https://x-access-token:gh-token@github.com/org/repo.git", got) } +func TestInjectTokenIntoURL_NeverEmbedsWorkspaceGitLabToken(t *testing.T) { + env := map[string]string{ + "GITLAB_TOKEN": "glpat-workspace", + "KANDEV_GITLAB_HOST": "http://gitlab.internal:8080", + } + got := injectGitHubTokenIntoCloneURL("http://gitlab.internal:8080/group/repo.git", env) + require.Equal(t, "http://gitlab.internal:8080/group/repo.git", got) + require.NotContains(t, got, "glpat-workspace") +} + +func TestInjectTokenIntoURL_NeverUsesGitLabTokenAcrossHosts(t *testing.T) { + env := map[string]string{ + "GITLAB_TOKEN": "glpat-workspace", + "KANDEV_GITLAB_HOST": "https://gitlab.internal", + } + got := injectGitHubTokenIntoCloneURL("https://gitlab.com/group/repo.git", env) + require.Equal(t, "https://gitlab.com/group/repo.git", got) +} + func TestIsTransientUploadError(t *testing.T) { require.True(t, isTransientUploadError(errors.New("request canceled (Client.Timeout exceeded while awaiting headers)"))) require.True(t, isTransientUploadError(errors.New("connection reset by peer"))) diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_helpers.go b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_helpers.go index 882daa6ed9..47e38551af 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_helpers.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_helpers.go @@ -16,7 +16,7 @@ import ( var uploadHTTPStatusRE = regexp.MustCompile(`(?i)\b(?:http|status)\s*:?\s*(\d{3})\b`) -// injectGitHubTokenIntoCloneURL injects a GitHub token into a clone URL so +// injectGitHubTokenIntoCloneURL injects only GitHub credentials into a clone URL so // in-container `git clone` works without prompting. Honours both // GITHUB_TOKEN and GH_TOKEN (gh CLI uses GH_TOKEN; Actions/most workflows // use GITHUB_TOKEN — accepting either keeps callers from a 401 because they @@ -25,21 +25,22 @@ var uploadHTTPStatusRE = regexp.MustCompile(`(?i)\b(?:http|status)\s*:?\s*(\d{3} // `https://x-access-token:T@github.com/` form, which works for both clone // and gh CLI authentication. // +// GitLab credentials are deliberately provided through an exact-origin +// ephemeral git credential helper in the execution environment, never in a URL. // Used by both Docker and Sprites executors. SSH→HTTPS rewrite is delegated // to rewriteGitHubSSHToHTTPS so the two surfaces never drift again. func injectGitHubTokenIntoCloneURL(cloneURL string, env map[string]string) string { - token := env["GITHUB_TOKEN"] - if token == "" { - token = env["GH_TOKEN"] + githubToken := env["GITHUB_TOKEN"] + if githubToken == "" { + githubToken = env["GH_TOKEN"] } - if token == "" { - return cloneURL - } - if converted := rewriteGitHubSSHToHTTPS(cloneURL); converted != "" { - cloneURL = converted - } - if strings.HasPrefix(cloneURL, "https://github.com/") { - return strings.Replace(cloneURL, "https://github.com/", "https://x-access-token:"+token+"@github.com/", 1) + if githubToken != "" { + if converted := rewriteGitHubSSHToHTTPS(cloneURL); converted != "" { + cloneURL = converted + } + if strings.HasPrefix(cloneURL, "https://github.com/") { + return strings.Replace(cloneURL, "https://github.com/", "https://x-access-token:"+githubToken+"@github.com/", 1) + } } return cloneURL } diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_execution_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_execution_test.go index e8aad3f163..e9bfa339ce 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_execution_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_execution_test.go @@ -981,6 +981,22 @@ func newReadyAgentctlClient(t *testing.T, log *logger.Logger) *agentctl.Client { return agentctl.NewClient(host, port, log) } +func TestWaitForAgentctlReadyCachesSuccessfulHealthCheck(t *testing.T) { + mgr := newTestManager(t) + execution := &AgentExecution{ + ID: "exec-prepared", + TaskID: "task-1", + SessionID: "session-1", + agentctl: newReadyAgentctlClient(t, mgr.logger), + } + + mgr.waitForAgentctlReady(execution) + + if !execution.IsAgentctlReady() { + t.Fatal("expected successful agentctl health check to be cached") + } +} + func containsString(s, substr string) bool { return len(s) >= len(substr) && searchString(s, substr) } diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_startup.go b/apps/backend/internal/agent/runtime/lifecycle/manager_startup.go index c10ab16891..743e5afdf5 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_startup.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_startup.go @@ -352,6 +352,7 @@ func (m *Manager) waitForAgentctlReady(execution *AgentExecution) { m.eventPublisher.PublishAgentctlEvent(ctx, events.AgentctlError, execution, err.Error()) return } + execution.MarkAgentctlReady() elapsed := time.Since(opStart) if elapsed > 10*time.Second { diff --git a/apps/backend/internal/agent/runtime/lifecycle/types.go b/apps/backend/internal/agent/runtime/lifecycle/types.go index a773121b4e..15a8df93dd 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/types.go +++ b/apps/backend/internal/agent/runtime/lifecycle/types.go @@ -6,6 +6,7 @@ import ( "context" "strings" "sync" + "sync/atomic" "time" "github.com/kandev/kandev/internal/agent/mcpconfig" @@ -58,6 +59,10 @@ type AgentExecution struct { // agentctl client for this execution agentctl *agentctl.Client + // agentctlReady records the successful health check independently of the + // agent process and workspace stream lifecycles. Prepared sessions have a + // healthy agentctl before either of those is started or attached. + agentctlReady atomic.Bool // Unified workspace stream for shell I/O, git status, and file changes workspaceStream *agentctl.WorkspaceStream @@ -179,6 +184,16 @@ func (ae *AgentExecution) GetAgentCtlClient() *agentctl.Client { return ae.agentctl } +// MarkAgentctlReady records that agentctl passed its startup health check. +func (ae *AgentExecution) MarkAgentctlReady() { + ae.agentctlReady.Store(true) +} + +// IsAgentctlReady reports whether agentctl passed its startup health check. +func (ae *AgentExecution) IsAgentctlReady() bool { + return ae.agentctlReady.Load() +} + // AgentctlURL returns the base URL of the agentctl HTTP server for this // execution. Returns an empty string when no agentctl client is set (e.g. // before the execution has been wired to an agentctl instance). diff --git a/apps/backend/internal/agentctl/AGENTS.md b/apps/backend/internal/agentctl/AGENTS.md index cdecb62a16..3ab2282d60 100644 --- a/apps/backend/internal/agentctl/AGENTS.md +++ b/apps/backend/internal/agentctl/AGENTS.md @@ -21,10 +21,11 @@ agentctl exposes these route groups (see `server/api/`): | Remote host | CLI | Notes | |-------------|-----|-------| | `github.com`, `*.github.com` | `gh pr create` | Requires `gh` on `PATH` (included in the Kandev Docker image). | +| `gitlab.com`, configured self-managed host | `glab mr create` or REST v4 | `KANDEV_GITLAB_HOST` binds detection/auth to the workspace origin. `GITLAB_TOKEN` is used only for that exact host; the REST fallback resolves the project default branch and reuses an existing open source/target MR on retry. | | `dev.azure.com`, `ssh.dev.azure.com`, `*.visualstudio.com` | `az repos pr create` | Requires `az` on `PATH`, `azure-devops` extension (both in the Kandev Docker image). Auth: `az login` or `AZURE_DEVOPS_EXT_PAT`. | -| Other hosts (e.g. GitLab) | — | Returns an unsupported-remote error. Use host-specific CLIs outside agentctl (e.g. `/pr` skill `glab` for GitLab). | +| Other hosts | — | Returns an unsupported-remote error. | -Azure PR URLs are returned to the client but do not trigger backend `onPRCreated` / TaskPR linkage (GitHub-only today). The web UI keeps a session-scoped pending PR URL so the changes panel hides **Create PR** after a successful Azure create. +GitHub PR and GitLab MR URLs trigger backend association callbacks. Azure PR URLs are returned to the client but do not trigger TaskPR linkage. The web UI keeps a session-scoped pending URL so the changes panel hides the create action after any successful provider response. ## Adapter Model diff --git a/apps/backend/internal/agentctl/server/process/git.go b/apps/backend/internal/agentctl/server/process/git.go index 16f39e7c6f..436b0d6a8c 100644 --- a/apps/backend/internal/agentctl/server/process/git.go +++ b/apps/backend/internal/agentctl/server/process/git.go @@ -40,6 +40,7 @@ type GitOperator struct { workDir string logger *logger.Logger workspaceTracker *WorkspaceTracker + environment func() []string // repoName is the multi-repo subpath this operator runs in (e.g. "kandev"). // Empty for the workspace-root operator. Stamped on emitted commit // notifications so the frontend can group commits per repo. @@ -56,6 +57,7 @@ func NewGitOperator(workDir string, log *logger.Logger, workspaceTracker *Worksp workDir: workDir, logger: log.WithFields(zap.String("component", "git-operator")), workspaceTracker: workspaceTracker, + environment: os.Environ, } } @@ -67,6 +69,30 @@ func NewGitOperatorForRepo(workDir, repoName string, log *logger.Logger, workspa return op } +func (g *GitOperator) setEnvironmentProvider(provider func() []string) { + if provider != nil { + g.environment = provider + } +} + +func (g *GitOperator) environmentValues() []string { + if g.environment == nil { + return os.Environ() + } + return g.environment() +} + +func (g *GitOperator) environmentValue(key string) string { + prefix := key + "=" + env := g.environmentValues() + for i := len(env) - 1; i >= 0; i-- { + if strings.HasPrefix(env[i], prefix) { + return strings.TrimPrefix(env[i], prefix) + } + } + return "" +} + // runGitCommand executes a git command in the workDir with defense-in-depth validation. // Validates both flags and branch/ref arguments to prevent command injection. func (g *GitOperator) runGitCommand(ctx context.Context, args ...string) (string, error) { @@ -143,6 +169,7 @@ func (g *GitOperator) runGitCommand(ctx context.Context, args ...string) (string // This defense-in-depth validation prevents injection of arbitrary commands. cmd := exec.CommandContext(ctx, "git", args...) // lgtm[go/command-injection] cmd.Dir = g.workDir + cmd.Env = filterGitEnv(g.environmentValues()) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout @@ -158,9 +185,14 @@ func (g *GitOperator) runGitCommand(ctx context.Context, args ...string) (string } output += stderr.String() } + stderrOutput := stderr.String() + if len(args) > 0 && args[0] == "push" { + output = g.sanitizeGitPushOutput(output) + stderrOutput = g.sanitizeGitPushOutput(stderrOutput) + } if err != nil { - return output, fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String())) + return output, fmt.Errorf("%w: %s", err, strings.TrimSpace(stderrOutput)) } return output, nil @@ -959,10 +991,12 @@ func (g *GitOperator) unlock() { // PRCreateResult represents the result of a PR creation operation. type PRCreateResult struct { - Success bool `json:"success"` - PRURL string `json:"pr_url,omitempty"` - Output string `json:"output,omitempty"` - Error string `json:"error,omitempty"` + Success bool `json:"success"` + BranchPushed bool `json:"branch_pushed,omitempty"` + PRURL string `json:"pr_url,omitempty"` + Provider string `json:"provider,omitempty"` + Output string `json:"output,omitempty"` + Error string `json:"error,omitempty"` } // CreatePR creates a pull request using the repository host's CLI. @@ -989,26 +1023,74 @@ func (g *GitOperator) CreatePR(ctx context.Context, title, body, baseBranch stri } g.logger.Debug("origin remote", zap.String("remote", redactRemoteURL(remoteURL))) - pushOutput, err := g.runGitCommand(ctx, "push", "--set-upstream", "origin", "HEAD") - if err != nil { - result.Error = fmt.Sprintf("failed to push branch: %s", pushOutput) - result.Output = pushOutput - return result, nil - } - g.logger.Debug("pushed branch to remote", zap.String("output", pushOutput)) - - switch detectPRProvider(remoteURL) { + provider := g.detectPRProvider(remoteURL) + var gitLabInfo *gitLabRepoInfo + switch provider { case prProviderAzureRepos: - return g.createAzureReposPR(ctx, result, remoteURL, branch, title, body, baseBranch, draft) + result.Provider = string(prProviderAzureRepos) + if _, parseErr := parseAzureRepoInfo(remoteURL); parseErr != nil { + result.Error = parseErr.Error() + return result, nil + } case prProviderGitHub: - return g.createGitHubPR(ctx, result, branch, title, body, baseBranch, draft) + result.Provider = string(prProviderGitHub) + case prProviderGitLab: + result.Provider = string(prProviderGitLab) + gitLabInfo, err = parseGitLabRepoInfo(remoteURL, g.environmentValue(gitLabHostEnv)) + if err != nil { + result.Error = err.Error() + return result, nil + } default: result.Error = fmt.Sprintf( - "unsupported git remote for PR creation: %s (GitHub and Azure Repos are supported)", + "unsupported git remote for PR creation: %s (GitHub, GitLab, and Azure Repos are supported)", redactRemoteURL(remoteURL), ) return result, nil } + + pushOutput, err := g.runGitCommand(ctx, "push", "--set-upstream", "origin", "HEAD") + if err != nil { + sanitizedOutput := g.sanitizePRFailure(pushOutput, title, body) + result.Error = fmt.Sprintf("failed to push branch: %s", sanitizedOutput) + result.Output = sanitizedOutput + return result, nil + } + g.logger.Debug("pushed branch to remote", zap.String("output", g.sanitizeGitPushOutput(pushOutput))) + + switch provider { + case prProviderAzureRepos: + created, createErr := g.createAzureReposPR(ctx, result, remoteURL, branch, title, body, baseBranch, draft) + return finalizePRCreationAfterPush(created, createErr) + case prProviderGitHub: + created, createErr := g.createGitHubPR(ctx, result, branch, title, body, baseBranch, draft) + return finalizePRCreationAfterPush(created, createErr) + case prProviderGitLab: + created, createErr := g.createGitLabPR(ctx, result, gitLabInfo, branch, title, body, baseBranch, draft) + return finalizePRCreationAfterPush(created, createErr) + default: + result.Error = "unsupported git remote for PR creation" + return result, nil + } +} + +func finalizePRCreationAfterPush(result *PRCreateResult, createErr error) (*PRCreateResult, error) { + if result == nil { + result = &PRCreateResult{} + } + if createErr == nil && result.Success { + return result, nil + } + requestName := "pull request" + if result.Provider == string(prProviderGitLab) { + requestName = "merge request" + } + result.Success = false + result.BranchPushed = true + result.PRURL = "" + result.Output = "" + result.Error = "branch was pushed; retry " + requestName + " creation" + return result, nil } // parseStatSummary parses a git --shortstat / --stat summary line like diff --git a/apps/backend/internal/agentctl/server/process/git_pr_providers.go b/apps/backend/internal/agentctl/server/process/git_pr_providers.go index 980442a953..99875fa533 100644 --- a/apps/backend/internal/agentctl/server/process/git_pr_providers.go +++ b/apps/backend/internal/agentctl/server/process/git_pr_providers.go @@ -6,21 +6,28 @@ import ( "encoding/json" "errors" "fmt" + "io" + "net/http" "net/url" "os" "os/exec" + "regexp" "strconv" "strings" + "time" "go.uber.org/zap" "github.com/kandev/kandev/internal/common/subproc" ) +var credentialURLPattern = regexp.MustCompile(`(?i)(https?://)[^\s/@]+@`) + type prProvider string const ( prProviderGitHub prProvider = "github" + prProviderGitLab prProvider = "gitlab" prProviderAzureRepos prProvider = "azure_repos" prCreateSubcommand = "create" repositoryFlagTitle = "--title" @@ -31,8 +38,12 @@ const ( azureDevOpsExtensionName = "azure-devops" errAzureCLIMissing = "azure CLI (az) is not on PATH; install it and run: az extension add --name azure-devops" errAzureDevOpsExtensionMissing = "azure DevOps CLI extension is not installed; run: az extension add --name azure-devops" + gitLabHostEnv = "KANDEV_GITLAB_HOST" + gitLabTokenEnv = "GITLAB_TOKEN" ) +const gitLabAPITimeout = 30 * time.Second + type azureRepoInfo struct { OrganizationURL string Project string @@ -47,6 +58,10 @@ type azurePRCreateResponse struct { } func detectPRProvider(remoteURL string) prProvider { + return detectPRProviderWithGitLabHost(remoteURL, os.Getenv(gitLabHostEnv)) +} + +func detectPRProviderWithGitLabHost(remoteURL, configuredGitLabHost string) prProvider { host := remoteHostFromURL(remoteURL) if isAzureReposHost(host) { return prProviderAzureRepos @@ -54,9 +69,24 @@ func detectPRProvider(remoteURL string) prProvider { if isGitHubHost(host) { return prProviderGitHub } + if isGitLabHost(host, configuredGitLabHost) { + return prProviderGitLab + } return "" } +func isGitLabHost(host, configuredGitLabHost string) bool { + if host == "gitlab.com" { + return true + } + configured, err := url.Parse(strings.TrimSpace(configuredGitLabHost)) + return err == nil && configured.Hostname() != "" && strings.EqualFold(host, configured.Hostname()) +} + +func (g *GitOperator) detectPRProvider(remoteURL string) prProvider { + return detectPRProviderWithGitLabHost(remoteURL, g.environmentValue(gitLabHostEnv)) +} + func isGitHubHost(host string) bool { return host == "github.com" || strings.HasSuffix(host, ".github.com") } @@ -314,6 +344,26 @@ func combineCommandOutput(stdout, stderr string) string { return strings.Join(parts, "\n") } +func sanitizePRFailure(message string, sensitiveValues ...string) string { + sanitized := credentialURLPattern.ReplaceAllString(message, `${1}`+redactedLogValue+"@") + values := append([]string{os.Getenv(gitLabTokenEnv)}, sensitiveValues...) + for _, value := range values { + if value != "" { + sanitized = strings.ReplaceAll(sanitized, value, redactedLogValue) + } + } + return sanitized +} + +func (g *GitOperator) sanitizePRFailure(message string, sensitiveValues ...string) string { + values := append([]string{g.environmentValue(gitLabTokenEnv)}, sensitiveValues...) + return sanitizePRFailure(message, values...) +} + +func (g *GitOperator) sanitizeGitPushOutput(output string) string { + return g.sanitizePRFailure(output) +} + func redactRemoteURL(remoteURL string) string { trimmed := strings.TrimSpace(remoteURL) if trimmed == "" { @@ -346,7 +396,7 @@ func (g *GitOperator) getOriginRemoteURL(ctx context.Context) (string, error) { func (g *GitOperator) runRepositoryCommand(ctx context.Context, name string, args ...string) (string, string, error) { cmd := exec.CommandContext(ctx, name, args...) cmd.Dir = g.workDir - cmd.Env = filterGitEnv(os.Environ()) + cmd.Env = filterGitEnv(g.environmentValues()) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout @@ -409,6 +459,325 @@ func (g *GitOperator) createGitHubPR( return result, nil } +func (g *GitOperator) createGitLabPR( + ctx context.Context, + result *PRCreateResult, + info *gitLabRepoInfo, + branch, title, body, baseBranch string, + draft bool, +) (*PRCreateResult, error) { + targetBranch, err := g.resolveGitLabTargetBranch(ctx, info, baseBranch) + if err != nil { + result.Error = err.Error() + return result, nil + } + + if _, lookupErr := exec.LookPath("glab"); lookupErr != nil { + return g.createGitLabPRWithREST(ctx, result, info, branch, title, body, targetBranch, draft) + } + if existingURL := g.findExistingGitLabMRWithGLab(ctx, info, branch, targetBranch); existingURL != "" { + result.PRURL = existingURL + result.Output = existingURL + result.Success = true + return result, nil + } + + args := []string{"mr", "create", repositoryFlagTitle, title, repositoryFlagDescription, body, "--source-branch", branch} + args = append(args, "--target-branch", targetBranch) + if draft { + args = append(args, "--draft") + } + args = append(args, "--yes") + + stdoutOutput, stderrOutput, err := g.runRepositoryCommand(ctx, "glab", args...) + result.Output = g.sanitizePRFailure(combineCommandOutput(stdoutOutput, stderrOutput), title, body) + if err != nil { + if g.environmentValue(gitLabTokenEnv) != "" { + return g.createGitLabPRWithREST(ctx, result, info, branch, title, body, targetBranch, draft) + } + result.Error = "GitLab merge request creation failed; authenticate glab for the configured host" + return result, nil + } + result.PRURL = extractGitLabMRURL(stdoutOutput, info) + result.Success = result.PRURL != "" + if !result.Success { + result.Error = "GitLab did not return a merge request URL" + } + return result, nil +} + +type gitLabRepoInfo struct { + Origin string + APIHost string + ProjectPath string +} + +type gitLabRemoteInfo struct { + Hostname string + HTTPOrigin string + ProjectPath string + SSH bool +} + +func parseGitLabRepoInfo(remoteURL, configuredOrigin string) (*gitLabRepoInfo, error) { + remote, err := parseGitLabRemote(remoteURL) + if err != nil { + return nil, err + } + origin := strings.TrimSpace(configuredOrigin) + if origin == "" { + if !strings.EqualFold(remote.Hostname, "gitlab.com") { + return nil, errors.New("GitLab host is not configured for this remote") + } + origin = "https://gitlab.com" + } + parsedOrigin, err := url.Parse(origin) + if err != nil || (parsedOrigin.Scheme != "http" && parsedOrigin.Scheme != "https") || parsedOrigin.Host == "" || parsedOrigin.User != nil || (parsedOrigin.Path != "" && parsedOrigin.Path != "/") { + return nil, errors.New("configured GitLab host must be an HTTP(S) origin") + } + normalizedOrigin := parsedOrigin.Scheme + "://" + parsedOrigin.Host + if remote.SSH { + if !strings.EqualFold(parsedOrigin.Hostname(), remote.Hostname) { + return nil, errors.New("GitLab remote host does not match the configured workspace host") + } + } else if !strings.EqualFold(normalizedOrigin, remote.HTTPOrigin) { + return nil, errors.New("GitLab remote host does not match the configured workspace host") + } + return &gitLabRepoInfo{ + Origin: normalizedOrigin, + APIHost: parsedOrigin.Host, + ProjectPath: remote.ProjectPath, + }, nil +} + +func parseGitLabRemote(remoteURL string) (*gitLabRemoteInfo, error) { + trimmed := strings.TrimSpace(remoteURL) + if strings.Contains(trimmed, "://") { + parsed, err := url.Parse(trimmed) + if err != nil || parsed.Hostname() == "" || parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, errors.New("invalid GitLab remote URL") + } + host, projectPath, err := validateGitLabRemoteParts(parsed.Hostname(), parsed.Path) + if err != nil { + return nil, err + } + switch parsed.Scheme { + case "http", "https": + if parsed.User != nil { + return nil, errors.New("GitLab HTTP remote URL must not contain credentials") + } + return &gitLabRemoteInfo{Hostname: host, HTTPOrigin: parsed.Scheme + "://" + parsed.Host, ProjectPath: projectPath}, nil + case "ssh": + return &gitLabRemoteInfo{Hostname: host, ProjectPath: projectPath, SSH: true}, nil + default: + return nil, errors.New("unsupported GitLab remote URL scheme") + } + } + withoutUser := trimmed + if _, after, ok := strings.Cut(withoutUser, "@"); ok { + withoutUser = after + } + host, path, ok := strings.Cut(withoutUser, ":") + if !ok { + return nil, errors.New("invalid GitLab SSH remote URL") + } + host, projectPath, err := validateGitLabRemoteParts(host, path) + if err != nil { + return nil, err + } + return &gitLabRemoteInfo{Hostname: host, ProjectPath: projectPath, SSH: true}, nil +} + +func validateGitLabRemoteParts(host, path string) (string, string, error) { + projectPath := strings.TrimSuffix(strings.Trim(strings.TrimSpace(path), "/"), ".git") + if host == "" || projectPath == "" || strings.Contains(projectPath, "..") || strings.Contains(projectPath, "/-/") { + return "", "", errors.New("invalid GitLab repository path") + } + return strings.ToLower(host), projectPath, nil +} + +type gitLabMRResponse struct { + WebURL string `json:"web_url"` + TargetBranch string `json:"target_branch"` +} + +func (g *GitOperator) findExistingGitLabMRWithGLab(ctx context.Context, info *gitLabRepoInfo, branch, targetBranch string) string { + args := []string{"mr", "list", "--source-branch", branch, "--target-branch", targetBranch, "--state", "opened", "--output", "json"} + stdoutOutput, _, err := g.runRepositoryCommand(ctx, "glab", args...) + if err != nil { + return "" + } + var rows []gitLabMRResponse + if json.Unmarshal([]byte(stdoutOutput), &rows) != nil { + return "" + } + for _, row := range rows { + if row.TargetBranch == targetBranch { + validated, validationErr := validateGitLabMRWebURL(row.WebURL, info) + if validationErr == nil { + return validated + } + } + } + return "" +} + +func extractGitLabMRURL(output string, info *gitLabRepoInfo) string { + for _, field := range strings.Fields(output) { + candidate := strings.Trim(field, "()[]{}<>,.;\"'") + if validated, err := validateGitLabMRWebURL(candidate, info); err == nil { + return validated + } + } + return "" +} + +func validateGitLabMRWebURL(raw string, info *gitLabRepoInfo) (string, error) { + candidate := strings.TrimSpace(raw) + parsed, err := url.Parse(candidate) + if err != nil || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", errors.New("invalid GitLab merge request URL") + } + if !strings.EqualFold(parsed.Scheme+"://"+parsed.Host, info.Origin) { + return "", errors.New("GitLab merge request URL does not match the configured workspace origin") + } + prefix := "/" + strings.Trim(info.ProjectPath, "/") + "/-/merge_requests/" + iid := strings.TrimPrefix(parsed.Path, prefix) + if !strings.HasPrefix(parsed.Path, prefix) || iid == "" || strings.Contains(iid, "/") { + return "", errors.New("GitLab merge request URL does not match the repository") + } + if _, err := strconv.Atoi(iid); err != nil { + return "", errors.New("GitLab merge request URL has an invalid IID") + } + return candidate, nil +} + +func (g *GitOperator) resolveGitLabTargetBranch(ctx context.Context, info *gitLabRepoInfo, baseBranch string) (string, error) { + if target := cleanBaseBranch(baseBranch); target != "" { + return target, nil + } + projectPath := "projects/" + url.PathEscape(info.ProjectPath) + if _, err := exec.LookPath("glab"); err == nil { + stdout, _, runErr := g.runRepositoryCommand(ctx, "glab", "api", projectPath, "--hostname", info.APIHost) + if runErr == nil { + var project struct { + DefaultBranch string `json:"default_branch"` + } + if json.Unmarshal([]byte(stdout), &project) == nil && strings.TrimSpace(project.DefaultBranch) != "" { + return strings.TrimSpace(project.DefaultBranch), nil + } + } + } + token := strings.TrimSpace(g.environmentValue(gitLabTokenEnv)) + if token == "" { + return "", errors.New("GitLab project default branch is unavailable; authenticate glab for the configured host") + } + var project struct { + DefaultBranch string `json:"default_branch"` + } + endpoint := info.Origin + "/api/v4/" + projectPath + if err := gitLabAPIJSON(ctx, &http.Client{Timeout: gitLabAPITimeout}, token, http.MethodGet, endpoint, nil, &project); err != nil { + return "", err + } + if strings.TrimSpace(project.DefaultBranch) == "" { + return "", errors.New("GitLab project did not return a default branch") + } + return strings.TrimSpace(project.DefaultBranch), nil +} + +func (g *GitOperator) createGitLabPRWithREST( + ctx context.Context, + result *PRCreateResult, + info *gitLabRepoInfo, + branch, title, body, targetBranch string, + draft bool, +) (*PRCreateResult, error) { + token := strings.TrimSpace(g.environmentValue(gitLabTokenEnv)) + if token == "" { + result.Error = "GitLab authentication is unavailable; install and authenticate glab or configure a workspace token" + return result, nil + } + client := &http.Client{Timeout: gitLabAPITimeout} + projectEndpoint := info.Origin + "/api/v4/projects/" + url.PathEscape(info.ProjectPath) + existingEndpoint := projectEndpoint + "/merge_requests?state=opened&source_branch=" + url.QueryEscape(branch) + "&target_branch=" + url.QueryEscape(targetBranch) + var existing []gitLabMRResponse + if err := gitLabAPIJSON(ctx, client, token, http.MethodGet, existingEndpoint, nil, &existing); err != nil { + result.Error = err.Error() + return result, nil + } + if len(existing) > 0 && existing[0].WebURL != "" { + validated, validationErr := validateGitLabMRWebURL(existing[0].WebURL, info) + if validationErr != nil { + result.Error = validationErr.Error() + return result, nil + } + result.PRURL = validated + result.Output = validated + result.Success = true + return result, nil + } + + if draft && !strings.HasPrefix(strings.ToLower(strings.TrimSpace(title)), "draft:") { + title = "Draft: " + title + } + payload := struct { + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + Title string `json:"title"` + Description string `json:"description"` + }{SourceBranch: branch, TargetBranch: targetBranch, Title: title, Description: body} + var created gitLabMRResponse + if err := gitLabAPIJSON(ctx, client, token, http.MethodPost, projectEndpoint+"/merge_requests", payload, &created); err != nil { + result.Error = err.Error() + return result, nil + } + if created.WebURL == "" { + result.Error = "GitLab did not return a merge request URL" + return result, nil + } + validated, validationErr := validateGitLabMRWebURL(created.WebURL, info) + if validationErr != nil { + result.Error = validationErr.Error() + return result, nil + } + result.PRURL = validated + result.Output = validated + result.Success = true + return result, nil +} + +func gitLabAPIJSON(ctx context.Context, client *http.Client, token, method, endpoint string, requestBody, responseBody any) error { + var body io.Reader + if requestBody != nil { + encoded, err := json.Marshal(requestBody) + if err != nil { + return errors.New("encode GitLab API request") + } + body = bytes.NewReader(encoded) + } + req, err := http.NewRequestWithContext(ctx, method, endpoint, body) + if err != nil { + return errors.New("build GitLab API request") + } + req.Header.Set("PRIVATE-TOKEN", token) + if requestBody != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("GitLab API request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20)) + return fmt.Errorf("GitLab API request failed with status %d", resp.StatusCode) + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(responseBody); err != nil { + return errors.New("decode GitLab API response") + } + return nil +} + func ensureAzureDevOpsCLI(ctx context.Context) error { if _, err := exec.LookPath("az"); err != nil { return errors.New(errAzureCLIMissing) diff --git a/apps/backend/internal/agentctl/server/process/git_pr_providers_test.go b/apps/backend/internal/agentctl/server/process/git_pr_providers_test.go new file mode 100644 index 0000000000..23bc7906f3 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/git_pr_providers_test.go @@ -0,0 +1,332 @@ +package process + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "testing" +) + +func prepareGitLabPRRepo(t *testing.T, remoteURL string) (*GitOperator, string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("shell wrapper test is Unix-only") + } + repoDir, cleanup := setupTestRepo(t) + t.Cleanup(cleanup) + runGit(t, repoDir, "checkout", "-b", "feature/gitlab-rest") + writeFile(t, repoDir, "gitlab-rest.txt", "gitlab rest\n") + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "add gitlab rest change") + scriptDir := t.TempDir() + writeGitRemoteWrapper(t, scriptDir, remoteURL) + t.Setenv("PATH", scriptDir) + return NewGitOperator(repoDir, newTestLogger(t), nil), scriptDir +} + +func TestGitOperatorCreatePR_GitLabRESTUsesJSONAndProjectDefaultBranch(t *testing.T) { + var createBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("PRIVATE-TOKEN"); got != "workspace-token" { + t.Errorf("PRIVATE-TOKEN = %q", got) + } + switch { + case r.Method == http.MethodGet && r.URL.EscapedPath() == "/api/v4/projects/group%2Fwidgets": + _ = json.NewEncoder(w).Encode(map[string]any{"default_branch": "develop"}) + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/merge_requests"): + _ = json.NewEncoder(w).Encode([]any{}) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/merge_requests"): + if err := json.NewDecoder(r.Body).Decode(&createBody); err != nil { + t.Errorf("decode request: %v", err) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "web_url": serverURL(r) + "/group/widgets/-/merge_requests/7", + }) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + op, _ := prepareGitLabPRRepo(t, server.URL+"/group/widgets.git") + t.Setenv("KANDEV_GITLAB_HOST", server.URL) + t.Setenv("GITLAB_TOKEN", "workspace-token") + result, err := op.CreatePR(context.Background(), "Quoted \"title\"\nline", "Body \"value\"", "", true) + if err != nil { + t.Fatalf("CreatePR: %v", err) + } + if !result.Success || result.Provider != "gitlab" { + t.Fatalf("result = %+v", result) + } + if createBody["target_branch"] != "develop" || createBody["source_branch"] != "feature/gitlab-rest" { + t.Fatalf("branch body = %#v", createBody) + } + if createBody["title"] != "Draft: Quoted \"title\"\nline" { + t.Fatalf("title body = %#v", createBody["title"]) + } +} + +func TestGitOperatorCreatePR_GitLabRESTRetryReusesExistingMR(t *testing.T) { + var posts atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.EscapedPath() == "/api/v4/projects/group%2Fwidgets": + _ = json.NewEncoder(w).Encode(map[string]any{"default_branch": "main"}) + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/merge_requests"): + _ = json.NewEncoder(w).Encode([]map[string]any{{ + "web_url": serverURL(r) + "/group/widgets/-/merge_requests/9", + "target_branch": "main", + }}) + case r.Method == http.MethodPost: + posts.Add(1) + http.Error(w, "duplicate", http.StatusConflict) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + op, _ := prepareGitLabPRRepo(t, "ssh://git@"+strings.TrimPrefix(server.URL, "http://")+"/group/widgets.git") + t.Setenv("KANDEV_GITLAB_HOST", server.URL) + t.Setenv("GITLAB_TOKEN", "workspace-token") + result, err := op.CreatePR(context.Background(), "Retry", "Body", "main", false) + if err != nil || !result.Success || !strings.HasSuffix(result.PRURL, "/merge_requests/9") { + t.Fatalf("result=%+v err=%v", result, err) + } + if posts.Load() != 0 { + t.Fatalf("create requests = %d, want 0", posts.Load()) + } +} + +func TestGitOperatorCreatePR_DoesNotSendGitLabTokenToMismatchedHost(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + http.Error(w, "unexpected", http.StatusInternalServerError) + })) + t.Cleanup(server.Close) + + op, _ := prepareGitLabPRRepo(t, "https://gitlab.com/acme/widgets.git") + t.Setenv("KANDEV_GITLAB_HOST", server.URL) + t.Setenv("GITLAB_TOKEN", "must-not-leak") + result, err := op.CreatePR(context.Background(), "Title", "Body", "main", false) + if err != nil { + t.Fatalf("CreatePR: %v", err) + } + if result.Success || !strings.Contains(strings.ToLower(result.Error), "host") { + t.Fatalf("result = %+v", result) + } + if calls.Load() != 0 { + t.Fatalf("configured host received %d requests", calls.Load()) + } +} + +func TestGitOperatorCreatePRValidatesGitLabOriginBeforePush(t *testing.T) { + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find git: %v", err) + } + op, scriptDir := prepareGitLabPRRepo(t, "https://gitlab.com/acme/widgets.git") + pushMarker := filepath.Join(scriptDir, "push-called") + writeExecutable(t, filepath.Join(scriptDir, "git"), fmt.Sprintf(`#!/bin/sh +if [ "$1" = "remote" ] && [ "$2" = "get-url" ]; then + printf 'https://gitlab.com/acme/widgets.git\n' + exit 0 +fi +if [ "$1" = "push" ]; then + touch %q + exit 0 +fi +exec %q "$@" +`, pushMarker, realGit)) + t.Setenv(gitLabHostEnv, "https://gitlab.internal") + t.Setenv(gitLabTokenEnv, "must-not-leak") + + result, createErr := op.CreatePR(context.Background(), "Title", "Body", "main", false) + if createErr != nil { + t.Fatalf("CreatePR: %v", createErr) + } + if result.Success || !strings.Contains(strings.ToLower(result.Error), "host") { + t.Fatalf("result = %+v", result) + } + if _, statErr := os.Stat(pushMarker); !os.IsNotExist(statErr) { + t.Fatalf("push ran before origin validation: %v", statErr) + } +} + +func TestSanitizeGitPushOutputRedactsCredentialBearingURLsAndTokens(t *testing.T) { + t.Setenv(gitLabTokenEnv, "process-token") + op := NewGitOperator(t.TempDir(), newTestLogger(t), nil) + op.setEnvironmentProvider(func() []string { return []string{gitLabTokenEnv + "=workspace-token"} }) + got := op.sanitizeGitPushOutput( + "remote: https://oauth2:embedded-token@gitlab.example/g/r.git process-token workspace-token", + ) + for _, secret := range []string{"oauth2", "embedded-token", "process-token", "workspace-token"} { + if strings.Contains(got, secret) { + t.Fatalf("sanitized push output contains %q: %q", secret, got) + } + } +} + +func TestGitOperatorCreatePRReturnsOnlySanitizedPushFailure(t *testing.T) { + op, scriptDir := prepareGitLabPRRepo(t, "https://gitlab.com/acme/widgets.git") + writeExecutable(t, filepath.Join(scriptDir, "git"), `#!/bin/sh +if [ "$1" = "remote" ] && [ "$2" = "get-url" ]; then + printf 'https://gitlab.com/acme/widgets.git\n' + exit 0 +fi +if [ "$1" = "push" ]; then + printf 'fatal: https://oauth2:embedded-token@gitlab.com/acme/widgets.git workspace-token\n' >&2 + exit 1 +fi +exit 9 +`) + t.Setenv(gitLabTokenEnv, "workspace-token") + + result, err := op.CreatePR(context.Background(), "Sensitive title", "Sensitive body", "main", false) + if err != nil { + t.Fatalf("CreatePR: %v", err) + } + combined := result.Error + "\n" + result.Output + for _, secret := range []string{"oauth2", "embedded-token", "workspace-token", "Sensitive title", "Sensitive body"} { + if strings.Contains(combined, secret) { + t.Fatalf("push failure contains %q: %+v", secret, result) + } + } +} + +func serverURL(r *http.Request) string { + return fmt.Sprintf("http://%s", r.Host) +} + +func TestGitLabFailureSanitizationRedactsSensitiveValues(t *testing.T) { + t.Setenv("GITLAB_TOKEN", "secret-token") + got := sanitizePRFailure( + `request for https://oauth2:secret-token@gitlab.example failed: secret-token Sensitive title`, + "Sensitive title", + ) + for _, secret := range []string{"secret-token", "Sensitive title", "oauth2:"} { + if strings.Contains(got, secret) { + t.Fatalf("sanitized error still contains %q: %q", secret, got) + } + } + if !strings.Contains(got, redactedLogValue) { + t.Fatalf("sanitized error = %q", got) + } +} + +func TestParseGitLabRepoInfoSupportsSelfManagedSSHAndSubgroups(t *testing.T) { + info, err := parseGitLabRepoInfo( + "git@gitlab.internal:group/subgroup/widgets.git", + "http://gitlab.internal:8080", + ) + if err != nil { + t.Fatalf("parseGitLabRepoInfo: %v", err) + } + if info.Origin != "http://gitlab.internal:8080" || info.ProjectPath != "group/subgroup/widgets" { + t.Fatalf("info = %#v", info) + } +} + +func TestParseGitLabRepoInfoRejectsHTTPOriginSchemeOrPortMismatch(t *testing.T) { + for _, remote := range []string{ + "http://gitlab.internal/group/widgets.git", + "https://gitlab.internal:8443/group/widgets.git", + } { + if _, err := parseGitLabRepoInfo(remote, "https://gitlab.internal"); err == nil { + t.Fatalf("accepted mismatched remote %q", remote) + } + } +} + +func TestGitOperatorCreatePRRejectsMismatchedRESTWebURL(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + _ = json.NewEncoder(w).Encode([]any{}) + case http.MethodPost: + _ = json.NewEncoder(w).Encode(map[string]any{ + "web_url": "https://attacker.example/group/widgets/-/merge_requests/7", + }) + } + })) + t.Cleanup(server.Close) + op, _ := prepareGitLabPRRepo(t, server.URL+"/group/widgets.git") + t.Setenv(gitLabHostEnv, server.URL) + t.Setenv(gitLabTokenEnv, "workspace-token") + + result, err := op.CreatePR(context.Background(), "Title", "Body", "main", false) + if err != nil { + t.Fatalf("CreatePR: %v", err) + } + if result.Success || !result.BranchPushed || result.PRURL != "" || result.Output != "" || + result.Error != "branch was pushed; retry merge request creation" { + t.Fatalf("result = %+v", result) + } + if strings.Contains(result.Error, "attacker.example") { + t.Fatalf("partial result leaked provider details: %+v", result) + } +} + +func TestFinalizePRCreationAfterPushReturnsGenericProviderAwarePartialState(t *testing.T) { + for _, test := range []struct { + provider prProvider + want string + }{ + {provider: prProviderGitHub, want: "branch was pushed; retry pull request creation"}, + {provider: prProviderAzureRepos, want: "branch was pushed; retry pull request creation"}, + {provider: prProviderGitLab, want: "branch was pushed; retry merge request creation"}, + } { + result, err := finalizePRCreationAfterPush(&PRCreateResult{ + Provider: string(test.provider), + Output: "provider output containing secret-token", + Error: "provider failure containing secret-token", + }, errors.New("transport failure containing secret-token")) + if err != nil { + t.Fatalf("finalize %s: %v", test.provider, err) + } + if result.Success || !result.BranchPushed || result.Output != "" || result.PRURL != "" || result.Error != test.want { + t.Fatalf("finalize %s = %+v", test.provider, result) + } + if strings.Contains(fmt.Sprintf("%+v", result), "secret-token") { + t.Fatalf("finalize %s leaked provider details: %+v", test.provider, result) + } + } +} + +func TestGitOperatorCreatePRGlabUsesProjectDefaultBeforeExistingLookup(t *testing.T) { + op, scriptDir := prepareGitLabPRRepo(t, "git@gitlab.com:group/widgets.git") + writeExecutable(t, filepath.Join(scriptDir, "glab"), `#!/bin/sh +if [ "$1" = "api" ]; then + printf '{"default_branch":"develop"}\n' + exit 0 +fi +if [ "$1" = "mr" ] && [ "$2" = "list" ]; then + case " $* " in + *" --target-branch develop "*) ;; + *) exit 9 ;; + esac + printf '[{"web_url":"https://gitlab.com/group/widgets/-/merge_requests/1","target_branch":"main"},{"web_url":"https://gitlab.com/group/widgets/-/merge_requests/2","target_branch":"develop"}]\n' + exit 0 +fi +exit 8 +`) + t.Setenv("PATH", scriptDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + result, err := op.CreatePR(context.Background(), "Title", "Body", "", false) + if err != nil { + t.Fatalf("CreatePR: %v", err) + } + if !result.Success || result.PRURL != "https://gitlab.com/group/widgets/-/merge_requests/2" { + t.Fatalf("result = %+v", result) + } +} diff --git a/apps/backend/internal/agentctl/server/process/git_test.go b/apps/backend/internal/agentctl/server/process/git_test.go index 1136b5664a..c5a480ab95 100644 --- a/apps/backend/internal/agentctl/server/process/git_test.go +++ b/apps/backend/internal/agentctl/server/process/git_test.go @@ -188,7 +188,7 @@ func writeGitRemoteWrapper(t *testing.T, scriptDir, originURL string) { ) } -func TestGitOperatorCreatePR_UnsupportedGitLabRemote(t *testing.T) { +func TestGitOperatorCreatePR_GitLabUsesGLab(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("shell wrapper test is Unix-only") } @@ -204,6 +204,13 @@ func TestGitOperatorCreatePR_UnsupportedGitLabRemote(t *testing.T) { scriptDir := t.TempDir() writeGitRemoteWrapper(t, scriptDir, "git@gitlab.com:acme/widgets.git") + writeExecutable(t, filepath.Join(scriptDir, "glab"), `#!/bin/sh +if [ "$1" = "mr" ] && [ "$2" = "list" ]; then + printf '[]\n' + exit 0 +fi +printf 'https://gitlab.com/acme/widgets/-/merge_requests/42\n' +`) t.Setenv("PATH", scriptDir+string(os.PathListSeparator)+os.Getenv("PATH")) gitOp := NewGitOperator(repoDir, log, nil) @@ -211,11 +218,14 @@ func TestGitOperatorCreatePR_UnsupportedGitLabRemote(t *testing.T) { if err != nil { t.Fatalf("CreatePR returned error: %v", err) } - if result.Success { - t.Fatalf("CreatePR should fail for GitLab remote: %+v", result) + if !result.Success { + t.Fatalf("CreatePR should succeed for GitLab remote: %+v", result) } - if !strings.Contains(result.Error, "unsupported git remote") { - t.Fatalf("unexpected error: %q", result.Error) + if result.PRURL != "https://gitlab.com/acme/widgets/-/merge_requests/42" { + t.Fatalf("PRURL = %q", result.PRURL) + } + if result.Provider != "gitlab" { + t.Fatalf("Provider = %q, want gitlab", result.Provider) } } @@ -246,8 +256,8 @@ func TestGitOperatorCreatePR_AzureMissingCLI(t *testing.T) { if result.Success { t.Fatalf("CreatePR should fail without az: %+v", result) } - if result.Error != errAzureCLIMissing { - t.Fatalf("error = %q, want %q", result.Error, errAzureCLIMissing) + if !result.BranchPushed || result.Error != "branch was pushed; retry pull request creation" { + t.Fatalf("partial result = %+v", result) } } @@ -278,8 +288,8 @@ func TestGitOperatorCreatePR_AzureMissingExtension(t *testing.T) { if result.Success { t.Fatalf("CreatePR should fail without azure-devops extension: %+v", result) } - if result.Error != errAzureDevOpsExtensionMissing { - t.Fatalf("error = %q, want %q", result.Error, errAzureDevOpsExtensionMissing) + if !result.BranchPushed || result.Error != "branch was pushed; retry pull request creation" { + t.Fatalf("partial result = %+v", result) } } @@ -319,6 +329,8 @@ func TestDetectPRProvider(t *testing.T) { }, {name: "visualstudio", remote: "https://acme.visualstudio.com/platform/_git/widgets", want: prProviderAzureRepos}, {name: "github", remote: "https://github.com/acme/widgets.git", want: prProviderGitHub}, + {name: "gitlab https", remote: "https://gitlab.com/acme/widgets.git", want: prProviderGitLab}, + {name: "gitlab ssh", remote: "git@gitlab.com:acme/widgets.git", want: prProviderGitLab}, { name: "github path must not match azure substring", remote: "git@github.com:acme/dev.azure.com-docs.git", @@ -335,6 +347,16 @@ func TestDetectPRProvider(t *testing.T) { } } +func TestDetectPRProviderSelfManagedGitLabRequiresMatchingConfiguredHost(t *testing.T) { + t.Setenv("KANDEV_GITLAB_HOST", "http://gitlab.internal:8080") + if got := detectPRProvider("git@gitlab.internal:acme/widgets.git"); got != prProviderGitLab { + t.Fatalf("matching self-managed provider = %q, want gitlab", got) + } + if got := detectPRProvider("git@other.internal:acme/widgets.git"); got == prProviderGitLab { + t.Fatalf("mismatched provider = %q, must not be gitlab", got) + } +} + func TestRemoteHostFromURL(t *testing.T) { tests := []struct { name string diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go index 40ec55ba52..5d69f313c4 100644 --- a/apps/backend/internal/agentctl/server/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -681,6 +681,7 @@ func (m *Manager) GitOperator() *GitOperator { if m.gitOperator == nil { m.gitOperator = NewGitOperator(m.cfg.WorkDir, m.logger, m.workspaceTracker) + m.gitOperator.setEnvironmentProvider(m.gitEnvironment) } return m.gitOperator } @@ -710,10 +711,17 @@ func (m *Manager) GitOperatorFor(subpath string) (*GitOperator, error) { return op, nil } op := NewGitOperatorForRepo(full, cleaned, m.logger, m.workspaceTracker) + op.setEnvironmentProvider(m.gitEnvironment) m.gitOperatorsBySubpath[cleaned] = op return op, nil } +func (m *Manager) gitEnvironment() []string { + m.startMu.Lock() + defer m.startMu.Unlock() + return append([]string(nil), m.cfg.AgentEnv...) +} + // resolveSubpath normalises and validates a repo subpath relative to // cfg.WorkDir. Returns ("", "", nil) for the root (empty/"."); otherwise // returns the cleaned relative path and the absolute full path. diff --git a/apps/backend/internal/agentctl/server/process/manager_git_subpath_test.go b/apps/backend/internal/agentctl/server/process/manager_git_subpath_test.go index 69df498630..90340ed5f2 100644 --- a/apps/backend/internal/agentctl/server/process/manager_git_subpath_test.go +++ b/apps/backend/internal/agentctl/server/process/manager_git_subpath_test.go @@ -15,6 +15,28 @@ func newTestManager(t *testing.T, workDir string) *Manager { return NewManager(&config.InstanceConfig{WorkDir: workDir}, log) } +func TestManagerGitOperatorUsesInstanceGitLabEnvironment(t *testing.T) { + t.Setenv(gitLabHostEnv, "") + t.Setenv(gitLabTokenEnv, "") + + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + mgr := NewManager(&config.InstanceConfig{ + WorkDir: t.TempDir(), + AgentEnv: []string{ + gitLabHostEnv + "=http://gitlab.internal:8080", + gitLabTokenEnv + "=instance-token", + }, + }, log) + op := mgr.GitOperator() + + if got := op.detectPRProvider("git@gitlab.internal:acme/widgets.git"); got != prProviderGitLab { + t.Fatalf("instance-scoped provider = %q, want gitlab", got) + } + if got := op.environmentValue(gitLabTokenEnv); got != "instance-token" { + t.Fatalf("instance-scoped token = %q, want instance-token", got) + } +} + func TestManager_GitOperatorFor_EmptySubpathReturnsRoot(t *testing.T) { tmp := t.TempDir() mgr := newTestManager(t, tmp) diff --git a/apps/backend/internal/backendapp/adapters_test.go b/apps/backend/internal/backendapp/adapters_test.go index b4d72b111c..1367811c9f 100644 --- a/apps/backend/internal/backendapp/adapters_test.go +++ b/apps/backend/internal/backendapp/adapters_test.go @@ -250,6 +250,7 @@ func TestResolveForReviewRedetectsStoredMasterAfterClonePath(t *testing.T) { Name: "owner/repo", SourceType: "provider", Provider: "github", + ProviderHost: "https://github.com", ProviderOwner: "owner", ProviderName: "repo", DefaultBranch: "master", diff --git a/apps/backend/internal/backendapp/e2e_reset.go b/apps/backend/internal/backendapp/e2e_reset.go index 091c0d7acf..e22a64c168 100644 --- a/apps/backend/internal/backendapp/e2e_reset.go +++ b/apps/backend/internal/backendapp/e2e_reset.go @@ -12,6 +12,7 @@ import ( "github.com/kandev/kandev/internal/automation" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/github" + "github.com/kandev/kandev/internal/gitlab" sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" taskservice "github.com/kandev/kandev/internal/task/service" ) @@ -28,6 +29,7 @@ func registerE2EResetRoutes( taskSvc *taskservice.Service, automationSvc *automation.Service, githubSvc *github.Service, + gitlabSvc *gitlab.Service, log *logger.Logger, ) { mockMode := os.Getenv("KANDEV_MOCK_AGENT") @@ -36,7 +38,7 @@ func registerE2EResetRoutes( } api := router.Group("/api/v1/e2e") - api.DELETE("/reset/:workspaceId", handleE2EReset(repo, taskSvc, automationSvc, githubSvc, log)) + api.DELETE("/reset/:workspaceId", handleE2EReset(repo, taskSvc, automationSvc, githubSvc, gitlabSvc, log)) // Hidden-workflow factory: lets E2E tests cover the system-only // workflow path (e.g. improve-kandev) without depending on the real // bootstrap endpoint, which clones from GitHub and shells out to gh. @@ -56,6 +58,7 @@ func handleE2EReset( taskSvc *taskservice.Service, automationSvc *automation.Service, githubSvc *github.Service, + gitlabSvc *gitlab.Service, log *logger.Logger, ) gin.HandlerFunc { return func(c *gin.Context) { @@ -135,6 +138,17 @@ func handleE2EReset( deletedWatches = n } + var gitLabReset gitlab.E2EResetResult + if gitlabSvc != nil { + resetResult, resetErr := gitlabSvc.ResetWorkspaceE2E(ctx, workspaceID) + if resetErr != nil { + log.Error("e2e reset: GitLab workspace cleanup failed", zap.Error(resetErr)) + c.JSON(http.StatusInternalServerError, gin.H{errKey: resetErr.Error()}) + return + } + gitLabReset = resetResult + } + // Route through the task service (rather than a raw SQL DELETE) so // each delete spawns the async cleanup goroutine that stops the // agentctl instance and releases its port. Without this, instances @@ -185,10 +199,12 @@ func handleE2EReset( } c.JSON(http.StatusOK, gin.H{ - "deleted_tasks": deletedTasks, - "deleted_workflows": deletedWorkflows, - "deleted_automations": deletedAutomations, - "deleted_review_watches": deletedWatches, + "deleted_tasks": deletedTasks, + "deleted_workflows": deletedWorkflows, + "deleted_automations": deletedAutomations, + "deleted_review_watches": deletedWatches, + "deleted_gitlab_review_watches": gitLabReset.ReviewWatches, + "deleted_gitlab_issue_watches": gitLabReset.IssueWatches, }) } } diff --git a/apps/backend/internal/backendapp/gateway.go b/apps/backend/internal/backendapp/gateway.go index 65f6c337c0..91773558b2 100644 --- a/apps/backend/internal/backendapp/gateway.go +++ b/apps/backend/internal/backendapp/gateway.go @@ -16,6 +16,7 @@ import ( "github.com/kandev/kandev/internal/events/bus" gateways "github.com/kandev/kandev/internal/gateway/websocket" "github.com/kandev/kandev/internal/github" + "github.com/kandev/kandev/internal/gitlab" lspinstaller "github.com/kandev/kandev/internal/lsp/installer" notificationcontroller "github.com/kandev/kandev/internal/notifications/controller" notificationservice "github.com/kandev/kandev/internal/notifications/service" @@ -109,6 +110,7 @@ func provideGateway( taskRepo *sqliterepo.Repository, terminalRepo *terminalrepo.Repository, githubSvc *github.Service, + gitlabSvc *gitlab.Service, dataDir string, ) (*gateways.Gateway, *notificationservice.Service, *notificationcontroller.Controller, *terminalservice.Service, error) { gateway, err := gateways.Provide(log) @@ -162,14 +164,37 @@ func provideGateway( shellHandlers.RegisterHandlers(gateway.Dispatcher) gitHandlers := agenthandlers.NewGitHandlers(lifecycleMgr, &sessionReaderAdapter{repo: taskRepo, logger: log}, log) - if githubSvc != nil { - gitHandlers.SetOnPRCreated(func(ctx context.Context, sessionID, taskID, prURL, branch, repo string) { - // Resolve the multi-repo subpath to a per-task repository_id so the - // resulting TaskPR / PRWatch rows are scoped per-repo. Empty `repo` - // (single-repo task) yields empty repositoryID and preserves legacy - // 1:1 task→PR semantics. - repositoryID := resolveRepositoryIDForSubpath(ctx, taskRepo, taskID, repo, log) - githubSvc.AssociatePRByURL(ctx, sessionID, taskID, repositoryID, prURL, branch) + if githubSvc != nil || gitlabSvc != nil { + router := createdChangeAssociationRouter{ + resolveRepositoryID: func(ctx context.Context, taskID, repo string) string { + return resolveRepositoryIDForSubpath(ctx, taskRepo, taskID, repo, log) + }, + resolveWorkspaceID: func(ctx context.Context, taskID string) (string, error) { + if taskRepo == nil { + return "", fmt.Errorf("task repository unavailable") + } + task, err := taskRepo.GetTask(ctx, taskID) + if err != nil || task == nil || task.WorkspaceID == "" { + return "", fmt.Errorf("task workspace unavailable") + } + return task.WorkspaceID, nil + }, + } + if githubSvc != nil { + router.associateGitHub = githubSvc.AssociatePRByURL + } + if gitlabSvc != nil { + router.associateGitLab = func(ctx context.Context, workspaceID, taskID, repositoryID, mrURL string) error { + _, err := gitlabSvc.AssociateExistingMRByURL(ctx, workspaceID, taskID, repositoryID, mrURL) + return err + } + } + gitHandlers.SetOnPRCreated(func(ctx context.Context, sessionID, taskID, provider, prURL, branch, repo string) { + if err := router.associate(ctx, sessionID, taskID, provider, prURL, branch, repo); err != nil { + // Provider errors may contain request URLs or credentials. + // Keep the log generic; retrying creation re-runs association. + log.Warn("failed to associate created change request", zap.String("task_id", taskID)) + } }) } gitHandlers.SetOnBranchRenamed(func(ctx context.Context, sessionID, newName, repo string) { @@ -307,6 +332,45 @@ func provideGateway( return gateway, notificationSvc, notificationCtrl, terminalSvc, nil } +type createdChangeAssociationRouter struct { + resolveRepositoryID func(context.Context, string, string) string + resolveWorkspaceID func(context.Context, string) (string, error) + associateGitHub func(context.Context, string, string, string, string, string) + associateGitLab func(context.Context, string, string, string, string) error +} + +func (r createdChangeAssociationRouter) associate( + ctx context.Context, + sessionID, taskID, provider, changeURL, branch, repo string, +) error { + repositoryID := "" + if r.resolveRepositoryID != nil { + repositoryID = r.resolveRepositoryID(ctx, taskID, repo) + } + switch provider { + case "gitlab": + if r.associateGitLab == nil { + return nil + } + if repositoryID == "" { + return fmt.Errorf("GitLab repository unavailable") + } + if r.resolveWorkspaceID == nil { + return fmt.Errorf("GitLab workspace resolver unavailable") + } + workspaceID, err := r.resolveWorkspaceID(ctx, taskID) + if err != nil { + return err + } + return r.associateGitLab(ctx, workspaceID, taskID, repositoryID, changeURL) + case "github", "": + if r.associateGitHub != nil { + r.associateGitHub(ctx, sessionID, taskID, repositoryID, changeURL, branch) + } + } + return nil +} + // subscribeTerminalCleanup wires the terminal service to task.deleted / // task.archived (where archived_at is set). It cleans up PTYs and DB rows // for the task. diff --git a/apps/backend/internal/backendapp/gateway_git_association_test.go b/apps/backend/internal/backendapp/gateway_git_association_test.go new file mode 100644 index 0000000000..72a511c70a --- /dev/null +++ b/apps/backend/internal/backendapp/gateway_git_association_test.go @@ -0,0 +1,79 @@ +package backendapp + +import ( + "context" + "errors" + "testing" +) + +func TestCreatedChangeAssociationRouterRoutesGitLabSingleAndMultiRepo(t *testing.T) { + var got []string + router := createdChangeAssociationRouter{ + resolveRepositoryID: func(_ context.Context, _ string, repo string) string { + if repo == "" { + return "repo-primary" + } + return "repo-" + repo + }, + resolveWorkspaceID: func(context.Context, string) (string, error) { return "workspace-1", nil }, + associateGitLab: func(_ context.Context, workspaceID, taskID, repositoryID, mrURL string) error { + got = append(got, workspaceID+"|"+taskID+"|"+repositoryID+"|"+mrURL) + return nil + }, + } + for _, repo := range []string{"", "secondary"} { + if err := router.associate(context.Background(), "session-1", "task-1", "gitlab", "https://gitlab.example/g/r/-/merge_requests/4", "feature", repo); err != nil { + t.Fatalf("associate repo %q: %v", repo, err) + } + } + want := []string{ + "workspace-1|task-1|repo-primary|https://gitlab.example/g/r/-/merge_requests/4", + "workspace-1|task-1|repo-secondary|https://gitlab.example/g/r/-/merge_requests/4", + } + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("associations = %#v", got) + } +} + +func TestCreatedChangeAssociationRouterPreservesGitHubAndMissingServices(t *testing.T) { + var got string + router := createdChangeAssociationRouter{ + resolveRepositoryID: func(context.Context, string, string) string { return "repo-1" }, + associateGitHub: func(_ context.Context, sessionID, taskID, repositoryID, prURL, branch string) { + got = sessionID + "|" + taskID + "|" + repositoryID + "|" + prURL + "|" + branch + }, + } + if err := router.associate(context.Background(), "session-1", "task-1", "github", "https://github.com/g/r/pull/2", "feature", ""); err != nil { + t.Fatal(err) + } + if got != "session-1|task-1|repo-1|https://github.com/g/r/pull/2|feature" { + t.Fatalf("GitHub association = %q", got) + } + if err := router.associate(context.Background(), "session-1", "task-1", "gitlab", "url", "feature", ""); err != nil { + t.Fatalf("missing GitLab service should be a no-op: %v", err) + } +} + +func TestCreatedChangeAssociationRouterGitLabFailureCanRetry(t *testing.T) { + attempts := 0 + router := createdChangeAssociationRouter{ + resolveRepositoryID: func(context.Context, string, string) string { return "repo-1" }, + resolveWorkspaceID: func(context.Context, string) (string, error) { return "workspace-1", nil }, + associateGitLab: func(context.Context, string, string, string, string) error { + attempts++ + if attempts == 1 { + return errors.New("temporary failure containing sensitive provider output") + } + return nil + }, + } + if err := router.associate(context.Background(), "session-1", "task-1", "gitlab", "url", "feature", ""); err == nil { + t.Fatal("first association unexpectedly succeeded") + } + if err := router.associate(context.Background(), "session-1", "task-1", "gitlab", "url", "feature", ""); err != nil { + t.Fatalf("retry: %v", err) + } + if attempts != 2 { + t.Fatalf("attempts = %d", attempts) + } +} diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index 63ef87b3d4..19245e6bdb 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -128,11 +128,11 @@ const sessionUpdatedAtPayloadKey = "updated_at" // // Non-blocking by design — sendSessionData runs in the WS read loop, so a // network probe here would delay every subscribe/focus ACK by its timeout. -// Instead, treat the workspace stream's presence as the cached readiness -// signal: streamManager only attaches it AFTER waitForAgentctlReady's Health -// check succeeds. If the stream is wired we emit `agentctl_ready`; otherwise -// `agentctl_starting`. The subsequent waitForAgentctlReady event (or its -// error) will correct the status if the snapshot picked the wrong one. +// Instead, replay the readiness cached by waitForAgentctlReady's successful +// health check. Agent process and workspace stream state are deliberately not +// used here: prepared sessions have a healthy agentctl before either exists. +// The subsequent waitForAgentctlReady event (or its error) corrects the status +// if the snapshot runs while startup is still in progress. // // Emits no message when the session has no live execution — the lazy // create-on-terminal-connect path publishes events normally in that case. @@ -162,7 +162,7 @@ func appendAgentctlStatusMessage( payload["worktree_path"] = execution.WorkspacePath } action := ws.ActionSessionAgentctlStarting - if execution.GetWorkspaceStream() != nil { + if execution.IsAgentctlReady() { action = ws.ActionSessionAgentctlReady } @@ -542,11 +542,20 @@ func registerRoutes(p routeParams) { if p.services.GitHub != nil { p.services.GitHub.SetCascadeTaskDeleter(handoffSvc) } + if p.services.GitLab != nil { + p.services.GitLab.SetCascadeTaskDeleter(handoffSvc) + } // repoLookup validates a watcher's optional repository binding (workspace // ownership + default-branch fill) on create/update. Shared across the three // repo-less watchers; one concrete adapter satisfies each package's // structurally-identical RepositoryLookup interface. repoLookup := &repositoryLookupAdapter{svc: p.taskSvc} + if p.services.GitLab != nil { + p.services.GitLab.SetRepositoryLookup(repoLookup) + p.services.GitLab.SetWatchDependencyValidator(&gitLabWatchDependencyValidator{ + tasks: p.taskSvc, workflows: p.services.Workflow, agents: p.agentSettingsRepo, + }) + } if p.services.Jira != nil { p.services.Jira.SetTaskDeleter(handoffSvc) p.services.Jira.SetRepositoryLookup(repoLookup) @@ -787,7 +796,7 @@ func resolveRepositoryIDForSubpath(ctx context.Context, taskRepo *sqliterepo.Rep if err != nil || repo == nil { continue } - if repo.Name == subpath { + if repo.Name == subpath || worktree.SanitizeRepoDirName(repo.Name) == subpath { return link.RepositoryID } } @@ -1027,7 +1036,9 @@ func registerSecondaryRoutes( if p.services.Automation != nil { automationSvc = p.services.Automation.Service } - registerE2EResetRoutes(p.router, p.taskRepo, p.taskSvc, automationSvc, p.services.GitHub, p.log) + registerE2EResetRoutes( + p.router, p.taskRepo, p.taskSvc, automationSvc, p.services.GitHub, p.services.GitLab, p.log, + ) if officetestharness.Enabled() { var officeAgentSvc *officeagents.AgentService diff --git a/apps/backend/internal/backendapp/helpers_test.go b/apps/backend/internal/backendapp/helpers_test.go index 999ad46178..6af8669aed 100644 --- a/apps/backend/internal/backendapp/helpers_test.go +++ b/apps/backend/internal/backendapp/helpers_test.go @@ -185,7 +185,41 @@ func TestAppendAgentctlStatusMessage_IncludesWorkspacePathForReload(t *testing.T } } -func TestResolveRepositoryIDForSessionSubpathMatchesSanitizedRepositoryName(t *testing.T) { +func TestAppendAgentctlStatusMessage_ReplaysReadyForPreparedWorkspace(t *testing.T) { + log, err := logger.NewLogger(logger.LoggingConfig{ + Level: "error", + Format: "console", + OutputPath: "stdout", + }) + if err != nil { + t.Fatalf("NewLogger: %v", err) + } + mgr := lifecycle.NewManager(nil, nil, nil, nil, nil, nil, lifecycle.ExecutorFallbackDeny, t.TempDir(), log) + execution := &lifecycle.AgentExecution{ + ID: "exec-prepared", + TaskID: "task-1", + SessionID: "sess-prepared", + TaskEnvironmentID: "env-1", + WorkspacePath: filepath.Join(t.TempDir(), "scratch"), + } + execution.MarkAgentctlReady() + if err := mgr.ExecutionStoreForTesting().Add(execution); err != nil { + t.Fatalf("add execution: %v", err) + } + + msgs := appendAgentctlStatusMessage(context.Background(), mgr, execution.SessionID, nil, log) + if len(msgs) != 1 { + t.Fatalf("expected 1 message, got %d", len(msgs)) + } + if msgs[0].Action != ws.ActionSessionAgentctlReady { + t.Fatalf("expected action %q, got %q", ws.ActionSessionAgentctlReady, msgs[0].Action) + } + if execution.GetWorkspaceStream() != nil { + t.Fatal("prepared workspace should reproduce without an attached workspace stream") + } +} + +func TestResolveRepositoryIDForSubpathMatchesSanitizedRepositoryName(t *testing.T) { ctx := context.Background() dbPath := filepath.Join(t.TempDir(), "backendapp-session.db") dbConn, err := db.OpenSQLite(dbPath) @@ -242,6 +276,11 @@ func TestResolveRepositoryIDForSessionSubpathMatchesSanitizedRepositoryName(t *t }); err != nil { t.Fatalf("CreateRepository: %v", err) } + if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ + ID: "task-repository-1", TaskID: "task-1", RepositoryID: "repo-1", Position: 0, + }); err != nil { + t.Fatalf("CreateTaskRepository: %v", err) + } if err := repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ ID: "session-worktree-1", SessionID: "session-1", @@ -259,6 +298,10 @@ func TestResolveRepositoryIDForSessionSubpathMatchesSanitizedRepositoryName(t *t if got != "repo-1" { t.Fatalf("resolveRepositoryIDForSessionSubpath = %q, want repo-1", got) } + got = resolveRepositoryIDForSubpath(ctx, repo, "task-1", "kdlbs-kandev", log) + if got != "repo-1" { + t.Fatalf("resolveRepositoryIDForSubpath = %q, want repo-1", got) + } } func TestStopLifecycleManagerAllowsAgentctlInstanceCleanupWindow(t *testing.T) { diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index dd7d91e59b..7668c53a65 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -517,6 +517,7 @@ func startAgentInfrastructure( // orchestrator so review/issue watch events get turned into tasks. if services.GitLab != nil { orchestratorSvc.SetGitLabService(services.GitLab) + orchestratorSvc.SetGitLabCredentialResolver(services.GitLab) services.GitLab.SetTaskDeleter(&taskDeleterAdapter{svc: services.Task}) services.GitLab.SetTaskSessionChecker(&taskSessionCheckerAdapter{repo: repos.Task}) glPoller := gitlabpkg.NewPoller(services.GitLab, eventBus, log) @@ -647,7 +648,7 @@ func startGatewayAndServe( gateway, _, notificationCtrl, terminalSvc, err := provideGateway( ctx, log, eventBus, services.Task, services.User, orchestratorSvc, lifecycleMgr, agentRegistry, - repos.Notification, repos.Task, repos.Terminal, services.GitHub, + repos.Notification, repos.Task, repos.Terminal, services.GitHub, services.GitLab, cfg.ResolvedHomeDir(), ) if terminalSvc != nil { diff --git a/apps/backend/internal/backendapp/orchestrator.go b/apps/backend/internal/backendapp/orchestrator.go index fe962c9877..ecb016c8f1 100644 --- a/apps/backend/internal/backendapp/orchestrator.go +++ b/apps/backend/internal/backendapp/orchestrator.go @@ -689,7 +689,8 @@ type repositoryResolverAdapter struct { func (a *repositoryResolverAdapter) ResolveForReview( ctx context.Context, workspaceID, provider, owner, name, defaultBranch string, ) (string, string, error) { - existing, err := a.taskSvc.GetRepositoryByProviderInfo(ctx, workspaceID, provider, owner, name) + providerHost := "https://" + defaultProviderHostname(provider) + existing, err := a.taskSvc.GetRepositoryByProviderInfo(ctx, workspaceID, provider, providerHost, owner, name) if err != nil { return "", "", fmt.Errorf("lookup repository by provider info: %w", err) } @@ -711,6 +712,7 @@ func (a *repositoryResolverAdapter) ResolveForReview( repo, _, err := a.taskSvc.FindOrCreateRepository(ctx, &taskservice.FindOrCreateRepositoryRequest{ WorkspaceID: workspaceID, Provider: provider, + ProviderHost: providerHost, ProviderOwner: owner, ProviderName: name, DefaultBranch: defaultBranch, @@ -724,6 +726,17 @@ func (a *repositoryResolverAdapter) ResolveForReview( return repo.ID, baseBranch, nil } +func defaultProviderHostname(provider string) string { + switch strings.ToLower(provider) { + case "gitlab": + return "gitlab.com" + case "bitbucket": + return "bitbucket.org" + default: + return "github.com" + } +} + func (a *repositoryResolverAdapter) resolveReviewBaseBranch( ctx context.Context, repo *taskmodels.Repository, diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index cc0e811e61..4b88c87234 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -378,9 +378,15 @@ func initGitLabService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secr } if svc != nil { svc.SetSecretManager(adapter) + svc.SetWorkspaceSecretStore(secretadapter.New(secretsStore)) svc.SetEventBus(eventBus) if store, storeErr := gitlab.NewStore(dbPool.Writer(), dbPool.Reader()); storeErr == nil { svc.SetStore(store) + if migrationErr := gitlab.MigrateLegacyConnection( + context.Background(), store, secretadapter.New(secretsStore), adapter, adapter, hostStore, log, + ); migrationErr != nil { + log.Warn("GitLab legacy connection migration failed (non-fatal)", zap.Error(migrationErr)) + } } else { log.Warn("GitLab task-mr store unavailable (non-fatal)", zap.Error(storeErr)) } diff --git a/apps/backend/internal/backendapp/turn_adapters.go b/apps/backend/internal/backendapp/turn_adapters.go index 1b02a64857..8dc4865078 100644 --- a/apps/backend/internal/backendapp/turn_adapters.go +++ b/apps/backend/internal/backendapp/turn_adapters.go @@ -5,12 +5,14 @@ import ( "errors" "fmt" + settingsstore "github.com/kandev/kandev/internal/agent/settings/store" "github.com/kandev/kandev/internal/automation" "github.com/kandev/kandev/internal/azuredevops" "github.com/kandev/kandev/internal/github" "github.com/kandev/kandev/internal/task/models" taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite" taskservice "github.com/kandev/kandev/internal/task/service" + workflowservice "github.com/kandev/kandev/internal/workflow/service" ) // turnServiceAdapter adapts the task service to the orchestrator.TurnService interface. @@ -154,6 +156,37 @@ type repositoryLookupAdapter struct { svc *taskservice.Service } +type gitLabWatchDependencyValidator struct { + tasks *taskservice.Service + workflows *workflowservice.Service + agents settingsstore.Repository +} + +func (v *gitLabWatchDependencyValidator) WorkflowStepBelongs(ctx context.Context, workspaceID, workflowID, stepID string) (bool, error) { + workflow, err := v.tasks.GetWorkflow(ctx, workflowID) + if err != nil || workflow == nil || workflow.WorkspaceID != workspaceID { + return false, nil + } + step, err := v.workflows.GetStep(ctx, stepID) + if err != nil || step == nil { + return false, nil + } + return step.WorkflowID == workflowID, nil +} + +func (v *gitLabWatchDependencyValidator) AgentProfileBelongs(ctx context.Context, workspaceID string, profileID string) (bool, error) { + profile, err := v.agents.GetAgentProfile(ctx, profileID) + if err != nil || profile == nil { + return false, nil + } + return profile.WorkspaceID == "" || profile.WorkspaceID == workspaceID, nil +} + +func (v *gitLabWatchDependencyValidator) ExecutorProfileBelongs(ctx context.Context, _ string, profileID string) (bool, error) { + profile, err := v.tasks.GetExecutorProfile(ctx, profileID) + return err == nil && profile != nil, nil +} + func (a *repositoryLookupAdapter) GetRepository(ctx context.Context, id string) (string, string, bool) { repo, err := a.svc.GetRepository(ctx, id) if err != nil || repo == nil { diff --git a/apps/backend/internal/gateway/websocket/task_notifications.go b/apps/backend/internal/gateway/websocket/task_notifications.go index ee6b2ee4b3..9b22fee4ff 100644 --- a/apps/backend/internal/gateway/websocket/task_notifications.go +++ b/apps/backend/internal/gateway/websocket/task_notifications.go @@ -79,6 +79,7 @@ func RegisterTaskNotifications(ctx context.Context, eventBus bus.EventBus, hub * b.subscribe(eventBus, events.GitHubTaskPRUpdated, ws.ActionGitHubTaskPRUpdated) b.subscribe(eventBus, events.GitHubTaskCIOptionsUpdated, ws.ActionGitHubTaskCIOptionsUpdated) b.subscribe(eventBus, events.GitHubRateLimitUpdated, ws.ActionGitHubRateLimitUpdated) + b.subscribe(eventBus, events.GitLabTaskMRUpdated, ws.ActionGitLabTaskMRUpdated) go func() { <-ctx.Done() diff --git a/apps/backend/internal/gateway/websocket/task_notifications_test.go b/apps/backend/internal/gateway/websocket/task_notifications_test.go index 462e532399..b05c8690ff 100644 --- a/apps/backend/internal/gateway/websocket/task_notifications_test.go +++ b/apps/backend/internal/gateway/websocket/task_notifications_test.go @@ -39,7 +39,7 @@ func TestTaskEventBroadcaster_NoDuplicateSubscriptions(t *testing.T) { // // Update this number when adding or removing event subscriptions in // RegisterTaskNotifications — it is intentionally exact. - const wantSubscriptions = 54 + const wantSubscriptions = 55 if got := len(b.subscriptions); got != wantSubscriptions { t.Errorf("RegisterTaskNotifications created %d subscriptions, want %d — "+ "did an event get subscribed twice?", got, wantSubscriptions) @@ -54,6 +54,7 @@ func TestTaskEventBroadcaster_NoDuplicateSubscriptions(t *testing.T) { events.MessageUpdated, events.TaskSessionStateChanged, events.GitHubTaskPRUpdated, + events.GitLabTaskMRUpdated, } { subject := subject t.Run(subject, func(t *testing.T) { diff --git a/apps/backend/internal/gitlab/action_presets_service.go b/apps/backend/internal/gitlab/action_presets_service.go index 29032fdd20..f0ff4c8682 100644 --- a/apps/backend/internal/gitlab/action_presets_service.go +++ b/apps/backend/internal/gitlab/action_presets_service.go @@ -3,6 +3,9 @@ package gitlab import ( "context" "fmt" + "strings" + + "github.com/google/uuid" ) // GetActionPresetsOrDefault returns the workspace's stored presets, falling @@ -46,10 +49,10 @@ func (s *Service) UpdateActionPresets(ctx context.Context, req *UpdateActionPres current = &ActionPresets{WorkspaceID: req.WorkspaceID} } if req.MR != nil { - current.MR = *req.MR + current.MR = normalizeActionPresets(*req.MR) } if req.Issue != nil { - current.Issue = *req.Issue + current.Issue = normalizeActionPresets(*req.Issue) } if err := store.UpsertActionPresets(ctx, current); err != nil { return nil, fmt.Errorf("upsert action presets: %w", err) @@ -59,6 +62,25 @@ func (s *Service) UpdateActionPresets(ctx context.Context, req *UpdateActionPres return s.GetActionPresetsOrDefault(ctx, req.WorkspaceID) } +func normalizeActionPresets(presets []ActionPreset) []ActionPreset { + out := make([]ActionPreset, 0, len(presets)) + for _, preset := range presets { + preset.ID = strings.TrimSpace(preset.ID) + preset.Label = strings.TrimSpace(preset.Label) + preset.Hint = strings.TrimSpace(preset.Hint) + preset.Icon = strings.TrimSpace(preset.Icon) + preset.PromptTemplate = strings.TrimSpace(preset.PromptTemplate) + if preset.Label == "" || preset.PromptTemplate == "" { + continue + } + if preset.ID == "" { + preset.ID = uuid.NewString() + } + out = append(out, preset) + } + return out +} + // ResetActionPresets removes a workspace's stored presets, falling back to defaults. func (s *Service) ResetActionPresets(ctx context.Context, workspaceID string) (*ActionPresets, error) { if workspaceID == "" { diff --git a/apps/backend/internal/gitlab/action_presets_test.go b/apps/backend/internal/gitlab/action_presets_test.go index 39137ffb04..be098f0819 100644 --- a/apps/backend/internal/gitlab/action_presets_test.go +++ b/apps/backend/internal/gitlab/action_presets_test.go @@ -47,6 +47,28 @@ func TestService_UpdateActionPresets_PartialMerge(t *testing.T) { } } +func TestServiceUpdateActionPresetsNormalizesAndDropsBlankEntries(t *testing.T) { + svc := newServiceWithStore(t) + ctx := context.Background() + mrPresets := []ActionPreset{ + {Label: " Review carefully ", Hint: " inspect ", Icon: " eye ", PromptTemplate: " Review {{url}} "}, + {ID: "blank-label", Label: " ", PromptTemplate: "do work"}, + {ID: "blank-prompt", Label: "No prompt", PromptTemplate: " "}, + } + + got, err := svc.UpdateActionPresets(ctx, &UpdateActionPresetsRequest{WorkspaceID: "ws-1", MR: &mrPresets}) + if err != nil { + t.Fatalf("UpdateActionPresets: %v", err) + } + if len(got.MR) != 1 { + t.Fatalf("normalized MR presets = %+v, want one valid entry", got.MR) + } + preset := got.MR[0] + if preset.ID == "" || preset.Label != "Review carefully" || preset.Hint != "inspect" || preset.Icon != "eye" || preset.PromptTemplate != "Review {{url}}" { + t.Fatalf("normalized preset = %+v", preset) + } +} + func TestService_ResetActionPresets(t *testing.T) { svc := newServiceWithStore(t) ctx := context.Background() diff --git a/apps/backend/internal/gitlab/client.go b/apps/backend/internal/gitlab/client.go index 9511d98346..74e6ed5b0a 100644 --- a/apps/backend/internal/gitlab/client.go +++ b/apps/backend/internal/gitlab/client.go @@ -132,4 +132,19 @@ type Client interface { // SetMRAssignees replaces an MR's assignees (by user ID). SetMRAssignees(ctx context.Context, projectPath string, iid int, assigneeIDs []int) error + + // ListProjectMembers searches active project members eligible for reviewer + // assignment. IDs are GitLab's numeric user IDs. + ListProjectMembers(ctx context.Context, projectPath, query string) ([]ProjectMember, error) + + // SetMRReviewers replaces the complete MR reviewer list. An empty slice + // clears all reviewers. + SetMRReviewers(ctx context.Context, projectPath string, iid int, reviewerIDs []int64) error + + // Notification subscription state is read from and written to GitLab; it is + // not a Kandev automation watch. + GetMRSubscription(ctx context.Context, projectPath string, iid int) (*SubscriptionState, error) + SetMRSubscription(ctx context.Context, projectPath string, iid int, subscribed bool) (*SubscriptionState, error) + GetIssueSubscription(ctx context.Context, projectPath string, iid int) (*SubscriptionState, error) + SetIssueSubscription(ctx context.Context, projectPath string, iid int, subscribed bool) (*SubscriptionState, error) } diff --git a/apps/backend/internal/gitlab/client_helpers.go b/apps/backend/internal/gitlab/client_helpers.go index fe6328ee8e..2956c0693b 100644 --- a/apps/backend/internal/gitlab/client_helpers.go +++ b/apps/backend/internal/gitlab/client_helpers.go @@ -29,6 +29,7 @@ type rawMR struct { Author rawUser `json:"author"` Reviewers []rawUser `json:"reviewers"` Assignees []rawUser `json:"assignees"` + Labels []string `json:"labels"` ChangesCount string `json:"changes_count"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -37,6 +38,7 @@ type rawMR struct { } type rawUser struct { + ID int64 `json:"id"` Username string `json:"username"` Name string `json:"name"` AvatarURL string `json:"avatar_url"` @@ -134,6 +136,7 @@ func convertRawMR(raw *rawMR) *MR { HasConflicts: raw.HasConflicts, Reviewers: convertReviewers(raw.Reviewers), Assignees: convertReviewers(raw.Assignees), + Labels: append([]string(nil), raw.Labels...), CreatedAt: raw.CreatedAt, UpdatedAt: raw.UpdatedAt, MergedAt: raw.MergedAt, @@ -157,6 +160,7 @@ func convertReviewers(raw []rawUser) []MRReviewer { continue } out = append(out, MRReviewer{ + ID: r.ID, Username: r.Username, Name: r.Name, Type: "user", diff --git a/apps/backend/internal/gitlab/client_helpers_test.go b/apps/backend/internal/gitlab/client_helpers_test.go index e08e0fa893..cd8afbd603 100644 --- a/apps/backend/internal/gitlab/client_helpers_test.go +++ b/apps/backend/internal/gitlab/client_helpers_test.go @@ -1,10 +1,33 @@ package gitlab import ( + "encoding/json" "net/url" "testing" ) +func TestConvertReviewersPreservesNumericUserID(t *testing.T) { + var user rawUser + if err := json.Unmarshal([]byte(`{"id":42,"username":"alice","name":"Alice"}`), &user); err != nil { + t.Fatalf("unmarshal reviewer: %v", err) + } + reviewers := convertReviewers([]rawUser{user}) + encoded, err := json.Marshal(reviewers) + if err != nil { + t.Fatalf("marshal reviewers: %v", err) + } + if got, want := string(encoded), `[{"id":42,"username":"alice","name":"Alice","type":"user"}]`; got != want { + t.Fatalf("reviewers JSON = %s, want %s", got, want) + } +} + +func TestConvertRawMRHydratesLabels(t *testing.T) { + mr := convertRawMR(&rawMR{Labels: []string{"bug", "backend"}}) + if got, want := mr.Labels, []string{"bug", "backend"}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("labels = %#v, want %#v", got, want) + } +} + // User-supplied filter keys must override defaults seeded by the caller. // The previous implementation used values.Add(), which kept the default // alongside the user value (e.g. state=opened&state=closed) and GitLab diff --git a/apps/backend/internal/gitlab/constants.go b/apps/backend/internal/gitlab/constants.go index 84c29c46e4..d7eba3a61b 100644 --- a/apps/backend/internal/gitlab/constants.go +++ b/apps/backend/internal/gitlab/constants.go @@ -2,9 +2,10 @@ package gitlab // Auth method constants. const ( - AuthMethodNone = "none" - AuthMethodPAT = "pat" - AuthMethodGLab = "glab_cli" + AuthMethodNone = "none" + AuthMethodPAT = "pat" + AuthMethodGLab = "glab_cli" + AuthMethodEnvironment = "environment" ) const ( @@ -17,8 +18,17 @@ const ( pipelineStatusFailed = "failed" pipelineStatusSuccess = "success" secretNameTokenLower = "gitlab_token" + connectionUnavailable = "GitLab connection unavailable" ) // DefaultHost is the public GitLab.com host. Self-managed instances override // this via the per-workspace gitlab_host setting. const DefaultHost = "https://gitlab.com" + +// SecretKey is the legacy installation-wide token identifier. +const SecretKey = "GITLAB_TOKEN" + +// SecretKeyForWorkspace returns the workspace-owned PAT identifier. +func SecretKeyForWorkspace(workspaceID string) string { + return "gitlab:" + workspaceID + ":token" +} diff --git a/apps/backend/internal/gitlab/controller.go b/apps/backend/internal/gitlab/controller.go index a5007485bb..0e0d7f4a5a 100644 --- a/apps/backend/internal/gitlab/controller.go +++ b/apps/backend/internal/gitlab/controller.go @@ -26,10 +26,10 @@ func NewController(svc *Service, log *logger.Logger) *Controller { // RegisterHTTPRoutes registers the v1 HTTP surface. func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { api := router.Group("/api/v1/gitlab") + c.registerConfigRoutes(api) + c.RegisterTaskMRHTTPRoutes(api) + c.registerMemberSubscriptionRoutes(api) api.GET("/status", c.httpGetStatus) - api.POST("/token", c.httpConfigureToken) - api.DELETE("/token", c.httpClearToken) - api.POST("/host", c.httpConfigureHost) api.GET("/mrs/feedback", c.httpGetMRFeedback) api.POST("/mrs/discussions/notes", c.httpCreateDiscussionNote) @@ -37,7 +37,6 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { api.GET("/workspaces/:workspaceID/task-mrs", c.httpListWorkspaceTaskMRs) api.GET("/tasks/:taskID/mrs", c.httpListTaskMRs) - api.POST("/tasks/:taskID/mrs/sync", c.httpSyncTaskMR) api.GET("/user/mrs", c.httpSearchUserMRs) api.GET("/user/issues", c.httpSearchUserIssues) @@ -51,9 +50,13 @@ func RegisterRoutes(router *gin.Engine, svc *Service, log *logger.Logger) { } func (c *Controller) httpGetStatus(ctx *gin.Context) { - status, err := c.service.GetStatus(ctx.Request.Context()) + if c.workspaceID(ctx) == "" { + ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "workspace_id query parameter required"}) + return + } + status, err := c.service.GetStatusForWorkspace(ctx.Request.Context(), c.workspaceID(ctx)) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: "GitLab status unavailable"}) return } ctx.JSON(http.StatusOK, status) @@ -105,9 +108,14 @@ func (c *Controller) httpGetMRFeedback(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: err.Error()}) return } - feedback, err := c.service.GetMRFeedback(ctx.Request.Context(), projectPath, iid) + var feedback *MRFeedback + err = c.runWorkspaceClientAction(ctx, func(client Client) error { + var actionErr error + feedback, actionErr = client.GetMRFeedback(ctx.Request.Context(), projectPath, iid) + return actionErr + }) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + writeWorkspaceClientActionError(ctx, err, "merge request feedback") return } ctx.JSON(http.StatusOK, feedback) @@ -124,9 +132,14 @@ func (c *Controller) httpCreateDiscussionNote(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "invalid payload"}) return } - note, err := c.service.CreateMRDiscussionNote(ctx.Request.Context(), req.Project, req.IID, req.DiscussionID, req.Body) + var note *MRNote + err := c.runWorkspaceClientAction(ctx, func(client Client) error { + var actionErr error + note, actionErr = client.CreateMRDiscussionNote(ctx.Request.Context(), req.Project, req.IID, req.DiscussionID, req.Body) + return actionErr + }) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + writeWorkspaceClientActionError(ctx, err, "discussion reply") return } ctx.JSON(http.StatusOK, note) @@ -142,8 +155,11 @@ func (c *Controller) httpResolveDiscussion(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "invalid payload"}) return } - if err := c.service.ResolveMRDiscussion(ctx.Request.Context(), req.Project, req.IID, req.DiscussionID); err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + err := c.runWorkspaceClientAction(ctx, func(client Client) error { + return client.ResolveMRDiscussion(ctx.Request.Context(), req.Project, req.IID, req.DiscussionID) + }) + if err != nil { + writeWorkspaceClientActionError(ctx, err, "discussion resolve") return } ctx.JSON(http.StatusOK, gin.H{"resolved": true}) @@ -185,7 +201,7 @@ func (c *Controller) httpListWorkspaceTaskMRs(ctx *gin.Context) { } taskMRs, err := c.service.ListTaskMRsByWorkspace(ctx.Request.Context(), wsID) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: "GitLab task merge requests unavailable"}) return } ctx.JSON(http.StatusOK, TaskMRsResponse{TaskMRs: taskMRs}) @@ -199,7 +215,7 @@ func (c *Controller) httpListTaskMRs(ctx *gin.Context) { } mrs, err := c.service.ListTaskMRsByTask(ctx.Request.Context(), taskID) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: "GitLab task merge requests unavailable"}) return } ctx.JSON(http.StatusOK, gin.H{"task_mrs": mrs}) @@ -243,11 +259,15 @@ func (c *Controller) httpSearchUserMRs(ctx *gin.Context) { filter = translated } } - result, err := c.service.Client().SearchMRsPaged( + client, ok := c.workspaceClient(ctx) + if !ok { + return + } + result, err := client.SearchMRsPaged( ctx.Request.Context(), filter, customQuery, page, perPage, ) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + writeWorkspaceClientActionError(ctx, err, "merge request search") return } ctx.JSON(http.StatusOK, result) @@ -272,11 +292,15 @@ func (c *Controller) httpSearchUserIssues(ctx *gin.Context) { filter = translated } } - result, err := c.service.Client().ListIssuesPaged( + client, ok := c.workspaceClient(ctx) + if !ok { + return + } + result, err := client.ListIssuesPaged( ctx.Request.Context(), filter, customQuery, page, perPage, ) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + writeWorkspaceClientActionError(ctx, err, "issue search") return } ctx.JSON(http.StatusOK, result) @@ -293,9 +317,13 @@ func (c *Controller) httpSearchUserIssues(ctx *gin.Context) { func (c *Controller) translateMRFilter(ctx *gin.Context, filter string) (string, error) { var username string if filter == filterTokenReviewRequested { - u, err := c.service.Client().GetAuthenticatedUser(ctx.Request.Context()) + client, ok := c.workspaceClient(ctx) + if !ok { + return "", ErrWorkspaceRequired + } + u, err := client.GetAuthenticatedUser(ctx.Request.Context()) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + writeWorkspaceClientActionError(ctx, err, "authenticated user lookup") return "", err } if u == "" { diff --git a/apps/backend/internal/gitlab/controller_config.go b/apps/backend/internal/gitlab/controller_config.go new file mode 100644 index 0000000000..4785287ba3 --- /dev/null +++ b/apps/backend/internal/gitlab/controller_config.go @@ -0,0 +1,145 @@ +package gitlab + +import ( + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +func (c *Controller) registerConfigRoutes(api *gin.RouterGroup) { + api.GET("/config", c.httpGetConfig) + api.PUT("/config", c.httpSetConfig) + api.DELETE("/config", c.httpDeleteConfig) + api.POST("/config/test", c.httpTestConfig) + api.POST("/config/copy", c.httpCopyConfig) +} + +func (c *Controller) httpCopyConfig(ctx *gin.Context) { + var req struct { + TargetWorkspaceID string `json:"targetWorkspaceId"` + } + if err := ctx.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.TargetWorkspaceID) == "" { + ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "targetWorkspaceId required"}) + return + } + cfg, err := c.service.CopyConfigToWorkspace(ctx.Request.Context(), c.workspaceID(ctx), req.TargetWorkspaceID) + if err != nil { + writeConfigMutationError(ctx, err) + return + } + ctx.JSON(http.StatusOK, cfg) +} + +func (c *Controller) workspaceID(ctx *gin.Context) string { + return strings.TrimSpace(ctx.Query("workspace_id")) +} + +func (c *Controller) workspaceClient(ctx *gin.Context) (Client, bool) { + workspaceID := c.workspaceID(ctx) + if workspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "workspace_id query parameter required"}) + return nil, false + } + client, err := c.service.ClientForWorkspaceHost( + ctx.Request.Context(), workspaceID, ctx.Query("expected_host"), + ) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, ErrWorkspaceHostMismatch) { + status = http.StatusNotFound + } else if errors.Is(err, ErrNotConfigured) { + status = http.StatusServiceUnavailable + } + message := providerActionFailedMessage + ": connection resolution" + if status == http.StatusNotFound { + message = "GitLab resource not found" + } + ctx.JSON(status, gin.H{responseErrorKey: message}) + return nil, false + } + return client, true +} + +func (c *Controller) runWorkspaceClientAction(ctx *gin.Context, action func(Client) error) error { + return c.service.RunWithWorkspaceClient( + ctx.Request.Context(), c.workspaceID(ctx), ctx.Query("expected_host"), action, + ) +} + +func writeWorkspaceClientActionError(ctx *gin.Context, err error, action string) { + switch { + case errors.Is(err, ErrWorkspaceRequired): + ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "workspace_id query parameter required"}) + case errors.Is(err, ErrNotConfigured): + ctx.JSON(http.StatusServiceUnavailable, gin.H{responseErrorKey: providerActionFailedMessage + ": connection resolution"}) + default: + writeProviderActionError(ctx, err, action) + } +} + +func (c *Controller) httpSetConfig(ctx *gin.Context) { + var req SetConfigRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "invalid payload"}) + return + } + cfg, err := c.service.SetConfigForWorkspace(ctx.Request.Context(), c.workspaceID(ctx), &req) + if err != nil { + writeConfigMutationError(ctx, err) + return + } + ctx.JSON(http.StatusOK, cfg) +} + +func (c *Controller) httpDeleteConfig(ctx *gin.Context) { + if err := c.service.DeleteConfigForWorkspace(ctx.Request.Context(), c.workspaceID(ctx)); err != nil { + writeConfigMutationError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +func (c *Controller) httpTestConfig(ctx *gin.Context) { + var req SetConfigRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "invalid payload"}) + return + } + result := c.service.TestConfigForWorkspace(ctx.Request.Context(), c.workspaceID(ctx), &req) + ctx.JSON(http.StatusOK, result) +} + +func (c *Controller) httpGetConfig(ctx *gin.Context) { + cfg, err := c.service.GetConfigForWorkspace(ctx.Request.Context(), c.workspaceID(ctx)) + if err != nil { + writeConfigMutationError(ctx, err) + return + } + if cfg == nil { + ctx.Status(http.StatusNoContent) + return + } + ctx.JSON(http.StatusOK, cfg) +} + +func writeConfigMutationError(ctx *gin.Context, err error) { + status := http.StatusInternalServerError + message := "GitLab connection operation failed" + switch { + case errors.Is(err, ErrWorkspaceRequired): + status = http.StatusBadRequest + message = "workspace_id query parameter required" + case errors.Is(err, ErrInvalidConfig): + status = http.StatusBadRequest + message = "invalid GitLab connection configuration" + case errors.Is(err, ErrSameWorkspace): + status = http.StatusBadRequest + message = "source and target workspace must differ" + case errors.Is(err, ErrNothingToCopy): + status = http.StatusBadRequest + message = "source workspace has no GitLab connection" + } + ctx.JSON(status, gin.H{responseErrorKey: message}) +} diff --git a/apps/backend/internal/gitlab/controller_config_test.go b/apps/backend/internal/gitlab/controller_config_test.go new file mode 100644 index 0000000000..d03b58433d --- /dev/null +++ b/apps/backend/internal/gitlab/controller_config_test.go @@ -0,0 +1,121 @@ +package gitlab + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +type configTestSecrets struct { + values map[string]string + setFailures int + deleteFailures int + mutateBeforeSetError bool + mutateBeforeDeleteError bool +} + +func (s *configTestSecrets) Reveal(_ context.Context, id string) (string, error) { + return s.values[id], nil +} +func (s *configTestSecrets) Set(_ context.Context, id, _ string, value string) error { + if s.setFailures > 0 { + s.setFailures-- + if s.mutateBeforeSetError { + s.values[id] = value + } + return errors.New("injected secret set failure") + } + s.values[id] = value + return nil +} +func (s *configTestSecrets) Delete(_ context.Context, id string) error { + if s.deleteFailures > 0 { + s.deleteFailures-- + if s.mutateBeforeDeleteError { + delete(s.values, id) + } + return errors.New("injected secret delete failure") + } + delete(s.values, id) + return nil +} +func (s *configTestSecrets) Exists(_ context.Context, id string) (bool, error) { + _, ok := s.values[id] + return ok, nil +} + +func TestControllerConfigReturnsNoContentWhenWorkspaceIsUnconfigured(t *testing.T) { + gin.SetMode(gin.TestMode) + store := newTestStore(t) + svc := NewService(DefaultHost, NewNoopClient(DefaultHost), AuthMethodNone, nil, newTestLogger(t)) + svc.SetStore(store) + router := gin.New() + NewController(svc, newTestLogger(t)).RegisterHTTPRoutes(router) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/gitlab/config?workspace_id=workspace-a", nil) + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + if res.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d; body=%s", res.Code, http.StatusNoContent, res.Body.String()) + } +} + +func TestControllerStatusRejectsMissingWorkspace(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := NewService(DefaultHost, NewNoopClient(DefaultHost), AuthMethodNone, nil, newTestLogger(t)) + router := gin.New() + NewController(svc, newTestLogger(t)).RegisterHTTPRoutes(router) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/gitlab/status", nil) + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + if res.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", res.Code, res.Body.String()) + } +} + +func TestControllerConfigSavesOnlyRequestedWorkspace(t *testing.T) { + gin.SetMode(gin.TestMode) + gitlabServer := httptest.NewServer(userHandler(func(token string) bool { return token == "workspace-token" })) + t.Cleanup(gitlabServer.Close) + + store := newTestStore(t) + seedWorkspace(t, store, "workspace-a") + seedWorkspace(t, store, "workspace-b") + svc := NewService(DefaultHost, NewNoopClient(DefaultHost), AuthMethodNone, nil, newTestLogger(t)) + svc.SetStore(store) + svc.SetWorkspaceSecretStore(&configTestSecrets{values: make(map[string]string)}) + router := gin.New() + NewController(svc, newTestLogger(t)).RegisterHTTPRoutes(router) + + body := []byte(`{"host":"` + gitlabServer.URL + `/","auth_method":"pat","token":"workspace-token"}`) + req := httptest.NewRequest(http.MethodPut, "/api/v1/gitlab/config?workspace_id=workspace-a", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + if res.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", res.Code, res.Body.String()) + } + cfgA, err := store.GetConfigForWorkspace(context.Background(), "workspace-a") + if err != nil || cfgA == nil { + t.Fatalf("workspace-a config = %#v, err=%v", cfgA, err) + } + if cfgA.Host != gitlabServer.URL { + t.Fatalf("workspace-a host = %q, want %q", cfgA.Host, gitlabServer.URL) + } + cfgB, err := store.GetConfigForWorkspace(context.Background(), "workspace-b") + if err != nil { + t.Fatalf("workspace-b config: %v", err) + } + if cfgB != nil { + t.Fatalf("workspace-b config leaked: %#v", cfgB) + } +} diff --git a/apps/backend/internal/gitlab/controller_member_subscriptions.go b/apps/backend/internal/gitlab/controller_member_subscriptions.go new file mode 100644 index 0000000000..736602a6bb --- /dev/null +++ b/apps/backend/internal/gitlab/controller_member_subscriptions.go @@ -0,0 +1,170 @@ +package gitlab + +import ( + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +const ( + providerActionFailedMessage = "GitLab action failed" + reviewerIneligibleMessage = "selected reviewer is not eligible" +) + +func (c *Controller) registerMemberSubscriptionRoutes(api *gin.RouterGroup) { + api.GET("/projects/members", c.httpListProjectMembers) + api.PUT("/mrs/reviewers", c.httpSetMRReviewers) + api.GET("/mrs/subscription", c.httpGetMRSubscription) + api.PUT("/mrs/subscription", c.httpSetMRSubscription) + api.GET("/issues/subscription", c.httpGetIssueSubscription) + api.PUT("/issues/subscription", c.httpSetIssueSubscription) +} + +func (c *Controller) httpListProjectMembers(ctx *gin.Context) { + project := strings.TrimSpace(ctx.Query("project")) + if project == "" { + ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "project query parameter required"}) + return + } + var members []ProjectMember + err := c.runWorkspaceClientAction(ctx, func(client Client) error { + var actionErr error + members, actionErr = client.ListProjectMembers(ctx.Request.Context(), project, ctx.Query("query")) + return actionErr + }) + if err != nil { + writeWorkspaceClientActionError(ctx, err, "project members") + return + } + ctx.JSON(http.StatusOK, members) +} + +func (c *Controller) httpSetMRReviewers(ctx *gin.Context) { + var req struct { + Project string `json:"project"` + IID int `json:"iid"` + ReviewerIDs *[]int64 `json:"reviewer_ids"` + } + if err := ctx.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Project) == "" || req.IID <= 0 || req.ReviewerIDs == nil { + ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "project, positive iid, and reviewer_ids are required"}) + return + } + var mr *MR + err := c.runWorkspaceClientAction(ctx, func(client Client) error { + if actionErr := client.SetMRReviewers(ctx.Request.Context(), req.Project, req.IID, *req.ReviewerIDs); actionErr != nil { + return actionErr + } + var actionErr error + mr, actionErr = client.GetMR(ctx.Request.Context(), req.Project, req.IID) + return actionErr + }) + if err != nil { + writeReviewerActionError(ctx, err) + return + } + ctx.JSON(http.StatusOK, mr) +} + +func (c *Controller) httpGetMRSubscription(ctx *gin.Context) { + c.httpGetSubscription(ctx, false) +} + +func (c *Controller) httpSetMRSubscription(ctx *gin.Context) { + c.httpSetSubscription(ctx, false) +} + +func (c *Controller) httpGetIssueSubscription(ctx *gin.Context) { + c.httpGetSubscription(ctx, true) +} + +func (c *Controller) httpSetIssueSubscription(ctx *gin.Context) { + c.httpSetSubscription(ctx, true) +} + +func (c *Controller) httpGetSubscription(ctx *gin.Context, issue bool) { + project, iid, err := parseProjectAndIID(ctx) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: err.Error()}) + return + } + var state *SubscriptionState + err = c.runWorkspaceClientAction(ctx, func(client Client) error { + if issue { + state, err = client.GetIssueSubscription(ctx.Request.Context(), project, iid) + } else { + state, err = client.GetMRSubscription(ctx.Request.Context(), project, iid) + } + return err + }) + if err != nil { + writeWorkspaceClientActionError(ctx, err, "subscription read") + return + } + ctx.JSON(http.StatusOK, state) +} + +func (c *Controller) httpSetSubscription(ctx *gin.Context, issue bool) { + var req struct { + Project string `json:"project"` + IID int `json:"iid"` + Subscribed *bool `json:"subscribed"` + } + if err := ctx.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Project) == "" || req.IID <= 0 || req.Subscribed == nil { + ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "project, positive iid, and subscribed are required"}) + return + } + var state *SubscriptionState + err := c.runWorkspaceClientAction(ctx, func(client Client) error { + var actionErr error + if issue { + state, actionErr = client.SetIssueSubscription(ctx.Request.Context(), req.Project, req.IID, *req.Subscribed) + } else { + state, actionErr = client.SetMRSubscription(ctx.Request.Context(), req.Project, req.IID, *req.Subscribed) + } + return actionErr + }) + if err != nil { + writeWorkspaceClientActionError(ctx, err, "subscription update") + return + } + ctx.JSON(http.StatusOK, state) +} + +func writeReviewerActionError(ctx *gin.Context, err error) { + var apiErr *APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusBadRequest { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{responseErrorKey: reviewerIneligibleMessage}) + return + } + writeProviderActionError(ctx, err, "reviewer update") +} + +func writeProviderActionError(ctx *gin.Context, err error, action string) { + status := http.StatusBadGateway + message := providerActionFailedMessage + ": " + action + switch { + case errors.Is(err, ErrWorkspaceRequired): + ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "workspace_id query parameter required"}) + return + case errors.Is(err, ErrNotConfigured): + ctx.JSON(http.StatusServiceUnavailable, gin.H{responseErrorKey: providerActionFailedMessage + ": connection resolution"}) + return + case errors.Is(err, ErrWorkspaceHostMismatch): + ctx.JSON(http.StatusNotFound, gin.H{responseErrorKey: "GitLab resource not found"}) + return + } + var apiErr *APIError + if errors.As(err, &apiErr) { + switch apiErr.StatusCode { + case http.StatusUnauthorized, http.StatusForbidden: + status = http.StatusForbidden + message = "GitLab permission denied" + case http.StatusNotFound: + status = http.StatusNotFound + message = "GitLab resource not found" + } + } + ctx.JSON(status, gin.H{responseErrorKey: message}) +} diff --git a/apps/backend/internal/gitlab/controller_member_subscriptions_test.go b/apps/backend/internal/gitlab/controller_member_subscriptions_test.go new file mode 100644 index 0000000000..dabcdce5a7 --- /dev/null +++ b/apps/backend/internal/gitlab/controller_member_subscriptions_test.go @@ -0,0 +1,237 @@ +package gitlab + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func newMemberSubscriptionController(t *testing.T, upstream http.Handler) (*gin.Engine, *Store, func()) { + t.Helper() + gin.SetMode(gin.TestMode) + srv := httptest.NewServer(upstream) + store := newTestStore(t) + seedWorkspace(t, store, "workspace-test") + if err := store.UpsertConfigForWorkspace(t.Context(), "workspace-test", &GitLabConfig{ + Host: srv.URL, AuthMethod: AuthMethodPAT, + }); err != nil { + t.Fatalf("seed GitLab config: %v", err) + } + svc := NewService(srv.URL, NewNoopClient(srv.URL), AuthMethodNone, nil, newTestLogger(t)) + svc.SetStore(store) + svc.SetWorkspaceSecretStore(&configTestSecrets{values: map[string]string{ + SecretKeyForWorkspace("workspace-test"): "tok", + }}) + router := gin.New() + NewController(svc, newTestLogger(t)).RegisterHTTPRoutes(router) + return router, store, srv.Close +} + +func memberSubscriptionRequest(router *gin.Engine, method, target, body string) *httptest.ResponseRecorder { + var payload *bytes.Reader + if body == "" { + payload = bytes.NewReader(nil) + } else { + payload = bytes.NewReader([]byte(body)) + } + req := httptest.NewRequest(method, target, payload) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + return res +} + +func TestControllerProjectMembersRequiresWorkspace(t *testing.T) { + router, _, stop := newMemberSubscriptionController(t, http.NotFoundHandler()) + defer stop() + + res := memberSubscriptionRequest(router, http.MethodGet, "/api/v1/gitlab/projects/members?project=group/project", "") + if res.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", res.Code, res.Body.String()) + } +} + +func TestControllerProjectMembersReturnsNumericIDs(t *testing.T) { + upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/projects/group/project/members/all") { + t.Errorf("path = %q", r.URL.Path) + } + _, _ = w.Write([]byte(`[{"id":42,"username":"alice","name":"Alice","avatar_url":"https://img/alice","state":"active"}]`)) + }) + router, _, stop := newMemberSubscriptionController(t, upstream) + defer stop() + + res := memberSubscriptionRequest(router, http.MethodGet, "/api/v1/gitlab/projects/members?workspace_id=workspace-test&project=group/project&query=ali", "") + if res.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", res.Code, res.Body.String()) + } + var members []ProjectMember + if err := json.Unmarshal(res.Body.Bytes(), &members); err != nil { + t.Fatalf("decode members: %v", err) + } + if len(members) != 1 || members[0].ID != 42 { + t.Fatalf("members = %#v, want numeric ID 42", members) + } +} + +func TestControllerSetMRReviewersAllowsEmptyReplacementAndRefreshes(t *testing.T) { + putCount := 0 + upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPut: + putCount++ + var payload struct { + ReviewerIDs []int64 `json:"reviewer_ids"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode reviewers: %v", err) + } + if payload.ReviewerIDs == nil || len(payload.ReviewerIDs) != 0 { + t.Fatalf("reviewer_ids = %#v, want non-nil empty list", payload.ReviewerIDs) + } + w.WriteHeader(http.StatusOK) + case http.MethodGet: + _, _ = w.Write([]byte(`{"id":1,"iid":12,"state":"opened","references":{"full":"group/project!12"},"reviewers":[]}`)) + default: + t.Errorf("method = %s", r.Method) + } + }) + router, _, stop := newMemberSubscriptionController(t, upstream) + defer stop() + + res := memberSubscriptionRequest(router, http.MethodPut, "/api/v1/gitlab/mrs/reviewers?workspace_id=workspace-test", `{"project":"group/project","iid":12,"reviewer_ids":[]}`) + if res.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", res.Code, res.Body.String()) + } + if putCount != 1 { + t.Fatalf("reviewer PUT count = %d, want 1", putCount) + } + var mr MR + if err := json.Unmarshal(res.Body.Bytes(), &mr); err != nil { + t.Fatalf("decode MR: %v", err) + } + if mr.IID != 12 { + t.Fatalf("MR IID = %d, want 12", mr.IID) + } +} + +func TestControllerSubscriptionsUseLiveUpstreamStateWithoutWatches(t *testing.T) { + upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/subscribe") { + _, _ = w.Write([]byte(`{"subscribed":true}`)) + return + } + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(`{"subscribed":false}`)) + return + } + http.NotFound(w, r) + }) + router, store, stop := newMemberSubscriptionController(t, upstream) + defer stop() + + getRes := memberSubscriptionRequest(router, http.MethodGet, "/api/v1/gitlab/issues/subscription?workspace_id=workspace-test&project=group/project&iid=9", "") + if getRes.Code != http.StatusOK || getRes.Body.String() != `{"subscribed":false}` { + t.Fatalf("GET status/body = %d/%s", getRes.Code, getRes.Body.String()) + } + putRes := memberSubscriptionRequest(router, http.MethodPut, "/api/v1/gitlab/mrs/subscription?workspace_id=workspace-test", `{"project":"group/project","iid":12,"subscribed":true}`) + if putRes.Code != http.StatusOK || putRes.Body.String() != `{"subscribed":true}` { + t.Fatalf("PUT status/body = %d/%s", putRes.Code, putRes.Body.String()) + } + + reviewWatches, err := store.ListReviewWatches(context.Background(), "workspace-test") + if err != nil { + t.Fatalf("list review watches: %v", err) + } + issueWatches, err := store.ListIssueWatches(context.Background(), "workspace-test") + if err != nil { + t.Fatalf("list issue watches: %v", err) + } + if len(reviewWatches) != 0 || len(issueWatches) != 0 { + t.Fatalf("notification toggle changed Kandev watches: review=%d issue=%d", len(reviewWatches), len(issueWatches)) + } +} + +func TestControllerProviderActionFailuresAreSanitized(t *testing.T) { + upstream := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message":"reviewer private@example.com is not eligible; token=glpat-secret"}`)) + }) + router, _, stop := newMemberSubscriptionController(t, upstream) + defer stop() + + res := memberSubscriptionRequest(router, http.MethodPut, "/api/v1/gitlab/mrs/reviewers?workspace_id=workspace-test", `{"project":"group/project","iid":12,"reviewer_ids":[999]}`) + if res.Code != http.StatusUnprocessableEntity { + t.Fatalf("status = %d, want 422; body=%s", res.Code, res.Body.String()) + } + if strings.Contains(res.Body.String(), "private@example.com") || strings.Contains(res.Body.String(), "glpat-secret") { + t.Fatalf("response leaked provider details: %s", res.Body.String()) + } + if !strings.Contains(res.Body.String(), "not eligible") { + t.Fatalf("response = %s, want action-specific sanitized error", res.Body.String()) + } +} + +func TestControllerMRReviewEndpointsSanitizeProviderFailures(t *testing.T) { + upstream := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message":"private@example.com token=glpat-secret"}`)) + }) + router, _, stop := newMemberSubscriptionController(t, upstream) + defer stop() + + tests := []struct { + name string + method string + target string + body string + }{ + {"feedback", http.MethodGet, "/api/v1/gitlab/mrs/feedback?workspace_id=workspace-test&project=group/project&iid=12", ""}, + {"discussion reply", http.MethodPost, "/api/v1/gitlab/mrs/discussions/notes?workspace_id=workspace-test", `{"project":"group/project","iid":12,"discussion_id":"d1","body":"reply"}`}, + {"discussion resolve", http.MethodPost, "/api/v1/gitlab/mrs/discussions/resolve?workspace_id=workspace-test", `{"project":"group/project","iid":12,"discussion_id":"d1"}`}, + {"merge", http.MethodPut, "/api/v1/gitlab/mrs/merge?workspace_id=workspace-test", `{"project":"group/project","iid":12,"method":"merge"}`}, + {"approve", http.MethodPost, "/api/v1/gitlab/mrs/approve?workspace_id=workspace-test", `{"project":"group/project","iid":12}`}, + {"unapprove", http.MethodPost, "/api/v1/gitlab/mrs/unapprove?workspace_id=workspace-test", `{"project":"group/project","iid":12}`}, + {"labels", http.MethodPut, "/api/v1/gitlab/mrs/labels?workspace_id=workspace-test", `{"project":"group/project","iid":12,"labels":["bug"]}`}, + {"assignees", http.MethodPut, "/api/v1/gitlab/mrs/assignees?workspace_id=workspace-test", `{"project":"group/project","iid":12,"assignee_ids":[1]}`}, + {"files", http.MethodGet, "/api/v1/gitlab/mrs/files?workspace_id=workspace-test&project=group/project&iid=12", ""}, + {"commits", http.MethodGet, "/api/v1/gitlab/mrs/commits?workspace_id=workspace-test&project=group/project&iid=12", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res := memberSubscriptionRequest(router, tt.method, tt.target, tt.body) + if res.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want 502; body=%s", res.Code, res.Body.String()) + } + if strings.Contains(res.Body.String(), "private@example.com") || strings.Contains(res.Body.String(), "glpat-secret") { + t.Fatalf("response leaked provider details: %s", res.Body.String()) + } + }) + } +} + +func TestControllerExpectedHostMismatchFailsBeforeProviderRequest(t *testing.T) { + requestCount := 0 + upstream := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requestCount++ + _, _ = w.Write([]byte(`{}`)) + }) + router, _, stop := newMemberSubscriptionController(t, upstream) + defer stop() + + res := memberSubscriptionRequest(router, http.MethodGet, "/api/v1/gitlab/mrs/feedback?workspace_id=workspace-test&expected_host=https%3A%2F%2Fother.example&project=group/project&iid=12", "") + if res.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404; body=%s", res.Code, res.Body.String()) + } + if requestCount != 0 { + t.Fatalf("provider requests = %d, want 0", requestCount) + } +} diff --git a/apps/backend/internal/gitlab/controller_task_mrs.go b/apps/backend/internal/gitlab/controller_task_mrs.go new file mode 100644 index 0000000000..9d7e37ca3f --- /dev/null +++ b/apps/backend/internal/gitlab/controller_task_mrs.go @@ -0,0 +1,83 @@ +package gitlab + +import ( + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +// RegisterTaskMRHTTPRoutes registers the explicit link/unlink endpoints on an +// existing /api/v1/gitlab router group. +func (c *Controller) RegisterTaskMRHTTPRoutes(api *gin.RouterGroup) { + api.POST("/task-mrs", c.httpCreateTaskMR) + api.DELETE("/task-mrs/:associationID", c.httpDeleteTaskMR) +} + +type createTaskMRRequest struct { + TaskID string `json:"task_id"` + RepositoryID string `json:"repository_id"` + MRURL string `json:"mr_url"` +} + +func (c *Controller) httpCreateTaskMR(ctx *gin.Context) { + workspaceID := c.workspaceID(ctx) + if workspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "workspace_id query parameter required"}) + return + } + var request createTaskMRRequest + if err := ctx.ShouldBindJSON(&request); err != nil || strings.TrimSpace(request.TaskID) == "" || strings.TrimSpace(request.MRURL) == "" { + ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "task_id and mr_url are required"}) + return + } + association, err := c.service.AssociateExistingMRByURL( + ctx.Request.Context(), workspaceID, request.TaskID, request.RepositoryID, request.MRURL, + ) + if err != nil { + writeTaskMRError(ctx, err) + return + } + ctx.JSON(http.StatusOK, association) +} + +func (c *Controller) httpDeleteTaskMR(ctx *gin.Context) { + workspaceID := c.workspaceID(ctx) + associationID := strings.TrimSpace(ctx.Param("associationID")) + if workspaceID == "" || associationID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "workspace_id and association_id are required"}) + return + } + if err := c.service.UnlinkTaskMR(ctx.Request.Context(), workspaceID, associationID); err != nil { + writeTaskMRError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +func writeTaskMRError(ctx *gin.Context, err error) { + status := http.StatusInternalServerError + message := "GitLab task merge request operation failed" + switch { + case errors.Is(err, ErrWorkspaceRequired): + status = http.StatusBadRequest + message = "workspace_id query parameter required" + case errors.Is(err, ErrInvalidMRURL): + status = http.StatusBadRequest + message = "invalid GitLab merge request URL" + case errors.Is(err, ErrTaskMRNotFound): + status = http.StatusNotFound + message = "task merge request association not found" + case errors.Is(err, ErrTaskMRRepositoryRequired): + status = http.StatusConflict + message = "repository_id is required for multi-repository tasks" + case errors.Is(err, ErrTaskMRRepositoryMismatch): + status = http.StatusUnprocessableEntity + message = "repository does not match GitLab merge request" + case errors.Is(err, ErrNotConfigured), errors.Is(err, ErrNoClient): + status = http.StatusServiceUnavailable + message = providerActionFailedMessage + ": connection resolution" + } + ctx.JSON(status, gin.H{responseErrorKey: message}) +} diff --git a/apps/backend/internal/gitlab/controller_task_mrs_test.go b/apps/backend/internal/gitlab/controller_task_mrs_test.go new file mode 100644 index 0000000000..c5c5f2ae90 --- /dev/null +++ b/apps/backend/internal/gitlab/controller_task_mrs_test.go @@ -0,0 +1,93 @@ +package gitlab + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" +) + +func newTaskMRControllerFixture(t *testing.T) (*gin.Engine, *Store) { + t.Helper() + gin.SetMode(gin.TestMode) + const host = "https://gitlab.acme.test" + svc, store, client := newTaskMRLinkService(t, host) + seedTaskMRLinkFixture(t, store, "ws-1", "task-1", "repo-1") + setTaskMRRepositoryIdentity(t, store, "repo-1", host, "group/project") + client.SeedMR("group/project", &MR{ + IID: 7, Title: "Review this", WebURL: host + "/group/project/-/merge_requests/7", + State: "opened", HeadBranch: "feature", BaseBranch: "main", CreatedAt: time.Now().UTC(), + }) + router := gin.New() + controller := NewController(svc, newTestLogger(t)) + controller.RegisterTaskMRHTTPRoutes(router.Group("/api/v1/gitlab")) + return router, store +} + +func TestTaskMRControllerCreatesAndUnlinksAssociation(t *testing.T) { + router, store := newTaskMRControllerFixture(t) + body := `{"task_id":"task-1","repository_id":"repo-1","mr_url":"https://gitlab.acme.test/group/project/-/merge_requests/7"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/gitlab/task-mrs?workspace_id=ws-1", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + if response.Code != http.StatusOK { + t.Fatalf("POST status = %d, body = %s", response.Code, response.Body.String()) + } + var association TaskMR + if err := json.Unmarshal(response.Body.Bytes(), &association); err != nil || association.ID == "" { + t.Fatalf("decode association: %+v, err = %v", association, err) + } + + deleteRequest := httptest.NewRequest( + http.MethodDelete, + "/api/v1/gitlab/task-mrs/"+association.ID+"?workspace_id=ws-1", + nil, + ) + deleteResponse := httptest.NewRecorder() + router.ServeHTTP(deleteResponse, deleteRequest) + if deleteResponse.Code != http.StatusOK { + t.Fatalf("DELETE status = %d, body = %s", deleteResponse.Code, deleteResponse.Body.String()) + } + rows, err := store.ListTaskMRsByTask(t.Context(), "task-1") + if err != nil || len(rows) != 0 { + t.Fatalf("rows after unlink = %d, err = %v", len(rows), err) + } +} + +func TestTaskMRControllerMapsScopeAndURLFailures(t *testing.T) { + router, _ := newTaskMRControllerFixture(t) + tests := []struct { + name string + target string + body string + want int + }{ + {name: "workspace required", target: "/api/v1/gitlab/task-mrs", body: `{}`, want: http.StatusBadRequest}, + { + name: "wrong host", target: "/api/v1/gitlab/task-mrs?workspace_id=ws-1", + body: `{"task_id":"task-1","repository_id":"repo-1","mr_url":"https://gitlab.com/group/project/-/merge_requests/7"}`, + want: http.StatusBadRequest, + }, + { + name: "unknown task", target: "/api/v1/gitlab/task-mrs?workspace_id=ws-1", + body: `{"task_id":"missing","repository_id":"repo-1","mr_url":"https://gitlab.acme.test/group/project/-/merge_requests/7"}`, + want: http.StatusNotFound, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, test.target, strings.NewReader(test.body)) + req.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + if response.Code != test.want { + t.Fatalf("status = %d, want %d; body = %s", response.Code, test.want, response.Body.String()) + } + }) + } +} diff --git a/apps/backend/internal/gitlab/controller_test.go b/apps/backend/internal/gitlab/controller_test.go index d994203e53..8c5e11b5e7 100644 --- a/apps/backend/internal/gitlab/controller_test.go +++ b/apps/backend/internal/gitlab/controller_test.go @@ -40,6 +40,18 @@ func newControllerFixture(t *testing.T, username string) (*gin.Engine, *requestL client := NewPATClient(srv.URL, "tok") svc := NewService(srv.URL, client, AuthMethodPAT, nil, log) + store := newTestStore(t) + seedWorkspace(t, store, "workspace-test") + secrets := &configTestSecrets{values: map[string]string{ + SecretKeyForWorkspace("workspace-test"): "tok", + }} + if err := store.UpsertConfigForWorkspace(t.Context(), "workspace-test", &GitLabConfig{ + Host: srv.URL, AuthMethod: AuthMethodPAT, + }); err != nil { + t.Fatalf("seed GitLab config: %v", err) + } + svc.SetStore(store) + svc.SetWorkspaceSecretStore(secrets) ctrl := NewController(svc, log) router := gin.New() ctrl.RegisterHTTPRoutes(router) @@ -75,6 +87,13 @@ func (r *requestLog) findByPath(path string) *recordedRequest { // hit issues a GET against the in-process router and returns the response. func hit(router *gin.Engine, target string) *httptest.ResponseRecorder { + separator := "?" + if strings.Contains(target, "?") { + separator = "&" + } + if !strings.Contains(target, "workspace_id=") { + target += separator + "workspace_id=workspace-test" + } req := httptest.NewRequest(http.MethodGet, target, nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) @@ -215,6 +234,17 @@ func TestHttpSearchUserMRs_ReviewRequestedWithoutUsername_Returns500(t *testing. gin.SetMode(gin.TestMode) log := newTestLogger(t) svc := NewService(srv.URL, NewPATClient(srv.URL, "tok"), AuthMethodPAT, nil, log) + store := newTestStore(t) + seedWorkspace(t, store, "workspace-test") + if err := store.UpsertConfigForWorkspace(t.Context(), "workspace-test", &GitLabConfig{ + Host: srv.URL, AuthMethod: AuthMethodPAT, + }); err != nil { + t.Fatalf("seed GitLab config: %v", err) + } + svc.SetStore(store) + svc.SetWorkspaceSecretStore(&configTestSecrets{values: map[string]string{ + SecretKeyForWorkspace("workspace-test"): "tok", + }}) router := gin.New() NewController(svc, log).RegisterHTTPRoutes(router) diff --git a/apps/backend/internal/gitlab/controller_watch_reset.go b/apps/backend/internal/gitlab/controller_watch_reset.go new file mode 100644 index 0000000000..62b859362b --- /dev/null +++ b/apps/backend/internal/gitlab/controller_watch_reset.go @@ -0,0 +1,117 @@ +package gitlab + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +func (c *Controller) httpPreviewResetReviewWatch(ctx *gin.Context) { + id := ctx.Param("id") + if !c.requireReviewWatchInWorkspace(ctx, id) { + return + } + n, err := c.service.PreviewResetReviewWatch(ctx.Request.Context(), id) + if err != nil { + c.writeWatchResetError(ctx, "preview reset review watch", err) + return + } + ctx.JSON(http.StatusOK, gin.H{"taskCount": n}) +} + +func (c *Controller) httpResetReviewWatch(ctx *gin.Context) { + id := ctx.Param("id") + if !c.requireReviewWatchInWorkspace(ctx, id) { + return + } + n, err := c.service.ResetReviewWatch(ctx.Request.Context(), id) + if err != nil { + c.writeWatchResetError(ctx, "reset review watch", err) + return + } + ctx.JSON(http.StatusOK, gin.H{"tasksDeleted": n}) +} + +func (c *Controller) httpPreviewResetIssueWatch(ctx *gin.Context) { + id := ctx.Param("id") + if !c.requireIssueWatchInWorkspace(ctx, id) { + return + } + n, err := c.service.PreviewResetIssueWatch(ctx.Request.Context(), id) + if err != nil { + c.writeWatchResetError(ctx, "preview reset issue watch", err) + return + } + ctx.JSON(http.StatusOK, gin.H{"taskCount": n}) +} + +func (c *Controller) httpResetIssueWatch(ctx *gin.Context) { + id := ctx.Param("id") + if !c.requireIssueWatchInWorkspace(ctx, id) { + return + } + n, err := c.service.ResetIssueWatch(ctx.Request.Context(), id) + if err != nil { + c.writeWatchResetError(ctx, "reset issue watch", err) + return + } + ctx.JSON(http.StatusOK, gin.H{"tasksDeleted": n}) +} + +func (c *Controller) requireReviewWatchInWorkspace(ctx *gin.Context, id string) bool { + watch, err := c.service.GetReviewWatch(ctx.Request.Context(), id) + if err != nil { + c.writeWatchResetError(ctx, "load review watch", err) + return false + } + if watch == nil || watch.WorkspaceID != c.workspaceID(ctx) { + ctx.JSON(http.StatusNotFound, gin.H{responseErrorKey: "review watch not found"}) + return false + } + return true +} + +func (c *Controller) requireIssueWatchInWorkspace(ctx *gin.Context, id string) bool { + watch, err := c.service.GetIssueWatch(ctx.Request.Context(), id) + if err != nil { + c.writeWatchResetError(ctx, "load issue watch", err) + return false + } + if watch == nil || watch.WorkspaceID != c.workspaceID(ctx) { + ctx.JSON(http.StatusNotFound, gin.H{responseErrorKey: "issue watch not found"}) + return false + } + return true +} + +func (c *Controller) requireReviewWatchDeleteInWorkspace(ctx *gin.Context, id string) bool { + watch, err := c.service.GetReviewWatchIncludingDeleting(ctx.Request.Context(), id) + if err != nil { + c.writeWatchResetError(ctx, "load review watch", err) + return false + } + if watch == nil || watch.WorkspaceID != c.workspaceID(ctx) { + ctx.JSON(http.StatusNotFound, gin.H{responseErrorKey: "review watch not found"}) + return false + } + return true +} + +func (c *Controller) requireIssueWatchDeleteInWorkspace(ctx *gin.Context, id string) bool { + watch, err := c.service.GetIssueWatchIncludingDeleting(ctx.Request.Context(), id) + if err != nil { + c.writeWatchResetError(ctx, "load issue watch", err) + return false + } + if watch == nil || watch.WorkspaceID != c.workspaceID(ctx) { + ctx.JSON(http.StatusNotFound, gin.H{responseErrorKey: "issue watch not found"}) + return false + } + return true +} + +func (c *Controller) writeWatchResetError(ctx *gin.Context, op string, err error) { + c.logger.Error(op, zap.Error(err)) + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: op + " failed"}) +} diff --git a/apps/backend/internal/gitlab/controller_watch_reset_test.go b/apps/backend/internal/gitlab/controller_watch_reset_test.go new file mode 100644 index 0000000000..26ddde981f --- /dev/null +++ b/apps/backend/internal/gitlab/controller_watch_reset_test.go @@ -0,0 +1,78 @@ +package gitlab + +import ( + "context" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestReviewWatchMutationGuardHidesCrossWorkspaceWatch(t *testing.T) { + gin.SetMode(gin.TestMode) + store := newTestStore(t) + watch := &ReviewWatch{ + WorkspaceID: "workspace-a", WorkflowID: "wf", WorkflowStepID: "step", + AgentProfileID: "agent", ExecutorProfileID: "executor", Enabled: true, + } + if err := store.CreateReviewWatch(context.Background(), watch); err != nil { + t.Fatalf("create watch: %v", err) + } + svc := NewService("", nil, "none", nil, newTestLogger(t)) + svc.SetStore(store) + controller := NewController(svc, newTestLogger(t)) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest("PATCH", "/api/v1/gitlab/watches/review/"+watch.ID+"?workspace_id=workspace-b", nil) + ctx.Params = gin.Params{{Key: "id", Value: watch.ID}} + + if controller.requireReviewWatchInWorkspace(ctx, watch.ID) { + t.Fatal("cross-workspace watch unexpectedly authorized") + } + if recorder.Code != 404 { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestWatchDeleteGuardsAllowOwnedTombstoneRetries(t *testing.T) { + gin.SetMode(gin.TestMode) + store := newTestStore(t) + review := &ReviewWatch{WorkspaceID: "workspace-a", WorkflowID: "wf", WorkflowStepID: "step", AgentProfileID: "agent", ExecutorProfileID: "executor", Enabled: true} + issue := &IssueWatch{WorkspaceID: "workspace-a", WorkflowID: "wf", WorkflowStepID: "step", AgentProfileID: "agent", ExecutorProfileID: "executor", Enabled: true} + if err := store.CreateReviewWatch(t.Context(), review); err != nil { + t.Fatal(err) + } + if err := store.CreateIssueWatch(t.Context(), issue); err != nil { + t.Fatal(err) + } + if _, err := store.BeginReviewWatchDelete(t.Context(), review.ID); err != nil { + t.Fatal(err) + } + if _, err := store.BeginIssueWatchDelete(t.Context(), issue.ID); err != nil { + t.Fatal(err) + } + service := NewService("", nil, AuthMethodNone, nil, newTestLogger(t)) + service.SetStore(store) + controller := NewController(service, newTestLogger(t)) + + for _, test := range []struct { + name string + id string + guard func(*gin.Context, string) bool + }{{"review", review.ID, controller.requireReviewWatchDeleteInWorkspace}, {"issue", issue.ID, controller.requireIssueWatchDeleteInWorkspace}} { + t.Run(test.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest("DELETE", "/?workspace_id=workspace-a", nil) + if !test.guard(ctx, test.id) { + t.Fatalf("owned tombstone retry rejected: status=%d body=%s", recorder.Code, recorder.Body.String()) + } + foreignRecorder := httptest.NewRecorder() + foreign, _ := gin.CreateTestContext(foreignRecorder) + foreign.Request = httptest.NewRequest("DELETE", "/?workspace_id=workspace-b", nil) + if test.guard(foreign, test.id) || foreignRecorder.Code != 404 { + t.Fatalf("foreign tombstone disclosed: status=%d body=%s", foreignRecorder.Code, foreignRecorder.Body.String()) + } + }) + } +} diff --git a/apps/backend/internal/gitlab/controller_watch_scope_test.go b/apps/backend/internal/gitlab/controller_watch_scope_test.go new file mode 100644 index 0000000000..22a4162027 --- /dev/null +++ b/apps/backend/internal/gitlab/controller_watch_scope_test.go @@ -0,0 +1,213 @@ +package gitlab + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func newWatchScopeFixture(t *testing.T) (*Store, *Service, *gin.Engine) { + t.Helper() + gin.SetMode(gin.TestMode) + store := newTestStore(t) + seedWorkspace(t, store, "workspace-a") + seedWorkspace(t, store, "workspace-b") + service := NewService(DefaultHost, NewMockClient(DefaultHost), AuthMethodPAT, nil, newTestLogger(t)) + service.SetStore(store) + service.workspaceClients = map[string]Client{ + "workspace-a": NewMockClient("https://gitlab-a.example.com"), + "workspace-b": NewMockClient("https://gitlab-b.example.com"), + } + router := gin.New() + NewController(service, newTestLogger(t)).RegisterHTTPRoutes(router) + return store, service, router +} + +func watchScopeRequest(t *testing.T, router *gin.Engine, method, target string, body any) *httptest.ResponseRecorder { + t.Helper() + var payload *bytes.Reader + if body == nil { + payload = bytes.NewReader(nil) + } else { + encoded, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + payload = bytes.NewReader(encoded) + } + req := httptest.NewRequest(method, target, payload) + req.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, req) + return recorder +} + +func TestMRWatchRoutesAreWorkspaceScoped(t *testing.T) { + store, _, router := newWatchScopeFixture(t) + seedTask(t, store, "task-a", "workspace-a") + seedTask(t, store, "task-b", "workspace-b") + watchA := &MRWatch{SessionID: "session-a", TaskID: "task-a", ProjectPath: "a/project", MRIID: 1} + watchB := &MRWatch{SessionID: "session-b", TaskID: "task-b", ProjectPath: "b/project", MRIID: 2} + for _, watch := range []*MRWatch{watchA, watchB} { + if err := store.CreateMRWatch(t.Context(), watch); err != nil { + t.Fatalf("create MR watch: %v", err) + } + } + + list := watchScopeRequest(t, router, http.MethodGet, + "/api/v1/gitlab/watches/mr?workspace_id=workspace-a", nil) + if list.Code != http.StatusOK { + t.Fatalf("list status=%d body=%s", list.Code, list.Body.String()) + } + if bytes.Contains(list.Body.Bytes(), []byte(watchB.ID)) { + t.Fatalf("workspace-a list disclosed workspace-b watch: %s", list.Body.String()) + } + if !bytes.Contains(list.Body.Bytes(), []byte(watchA.ID)) { + t.Fatalf("workspace-a list omitted own watch: %s", list.Body.String()) + } + + deleted := watchScopeRequest(t, router, http.MethodDelete, + "/api/v1/gitlab/watches/mr/"+watchB.ID+"?workspace_id=workspace-a", nil) + if deleted.Code != http.StatusNotFound { + t.Fatalf("cross-workspace delete status=%d body=%s", deleted.Code, deleted.Body.String()) + } + remaining, err := store.ListMRWatchesByTask(t.Context(), "task-b") + if err != nil || len(remaining) != 1 { + t.Fatalf("foreign MR watch was changed: rows=%d err=%v", len(remaining), err) + } +} + +func TestTriggerAllWatchRoutesOnlyDispatchRequestedWorkspace(t *testing.T) { + store, service, router := newWatchScopeFixture(t) + clientA := service.workspaceClients["workspace-a"].(*MockClient) + clientB := service.workspaceClients["workspace-b"].(*MockClient) + clientA.SeedMR("a/project", &MR{IID: 1, Title: "A", WebURL: "https://a/1", Reviewers: []MRReviewer{{Username: "kandev-tester"}}}) + clientB.SeedMR("b/project", &MR{IID: 2, Title: "B", WebURL: "https://b/2", Reviewers: []MRReviewer{{Username: "kandev-tester"}}}) + clientA.SeedIssue("a/project", &Issue{IID: 3, Title: "A", WebURL: "https://a/3"}) + clientB.SeedIssue("b/project", &Issue{IID: 4, Title: "B", WebURL: "https://b/4"}) + for _, watch := range []*ReviewWatch{ + {WorkspaceID: "workspace-a", WorkflowID: "wf", WorkflowStepID: "step", AgentProfileID: "agent", ExecutorProfileID: "exec", Enabled: true}, + {WorkspaceID: "workspace-b", WorkflowID: "wf", WorkflowStepID: "step", AgentProfileID: "agent", ExecutorProfileID: "exec", Enabled: true}, + } { + if err := store.CreateReviewWatch(t.Context(), watch); err != nil { + t.Fatalf("create review watch: %v", err) + } + } + for _, watch := range []*IssueWatch{ + {WorkspaceID: "workspace-a", WorkflowID: "wf", WorkflowStepID: "step", AgentProfileID: "agent", ExecutorProfileID: "exec", Enabled: true}, + {WorkspaceID: "workspace-b", WorkflowID: "wf", WorkflowStepID: "step", AgentProfileID: "agent", ExecutorProfileID: "exec", Enabled: true}, + } { + if err := store.CreateIssueWatch(t.Context(), watch); err != nil { + t.Fatalf("create issue watch: %v", err) + } + } + + for _, path := range []string{"/watches/review/trigger-all", "/watches/issue/trigger-all"} { + response := watchScopeRequest(t, router, http.MethodPost, + "/api/v1/gitlab"+path+"?workspace_id=workspace-a", nil) + if response.Code != http.StatusOK || !bytes.Contains(response.Body.Bytes(), []byte(`"count":1`)) { + t.Fatalf("%s status=%d body=%s", path, response.Code, response.Body.String()) + } + } +} + +type watchScopeTaskDeleter struct{ ids []string } + +func (d *watchScopeTaskDeleter) DeleteTask(_ context.Context, taskID string) error { + d.ids = append(d.ids, taskID) + return nil +} + +func TestCleanupWatchRoutesOnlyDeleteRequestedWorkspaceTasks(t *testing.T) { + store, service, router := newWatchScopeFixture(t) + deleter := &watchScopeTaskDeleter{} + service.SetTaskDeleter(deleter) + clientA := service.workspaceClients["workspace-a"].(*MockClient) + clientB := service.workspaceClients["workspace-b"].(*MockClient) + clientA.SeedMR("a/project", &MR{IID: 1, State: gitlabStateClosed}) + clientB.SeedMR("b/project", &MR{IID: 2, State: gitlabStateClosed}) + clientA.SeedIssue("a/project", &Issue{IID: 3, State: gitlabStateClosed}) + clientB.SeedIssue("b/project", &Issue{IID: 4, State: gitlabStateClosed}) + legacyClient := NewMockClient(DefaultHost) + legacyClient.SeedMR("a/project", &MR{IID: 1, State: gitlabStateClosed}) + legacyClient.SeedMR("b/project", &MR{IID: 2, State: gitlabStateClosed}) + legacyClient.SeedIssue("a/project", &Issue{IID: 3, State: gitlabStateClosed}) + legacyClient.SeedIssue("b/project", &Issue{IID: 4, State: gitlabStateClosed}) + service.client = legacyClient + + reviewA := &ReviewWatch{WorkspaceID: "workspace-a", WorkflowID: "wf", WorkflowStepID: "step", AgentProfileID: "agent", ExecutorProfileID: "exec", Enabled: true, CleanupPolicy: CleanupPolicyAlways} + reviewB := &ReviewWatch{WorkspaceID: "workspace-b", WorkflowID: "wf", WorkflowStepID: "step", AgentProfileID: "agent", ExecutorProfileID: "exec", Enabled: true, CleanupPolicy: CleanupPolicyAlways} + issueA := &IssueWatch{WorkspaceID: "workspace-a", WorkflowID: "wf", WorkflowStepID: "step", AgentProfileID: "agent", ExecutorProfileID: "exec", Enabled: true, CleanupPolicy: CleanupPolicyAlways} + issueB := &IssueWatch{WorkspaceID: "workspace-b", WorkflowID: "wf", WorkflowStepID: "step", AgentProfileID: "agent", ExecutorProfileID: "exec", Enabled: true, CleanupPolicy: CleanupPolicyAlways} + for _, watch := range []*ReviewWatch{reviewA, reviewB} { + if err := store.CreateReviewWatch(t.Context(), watch); err != nil { + t.Fatalf("create review watch: %v", err) + } + } + for _, watch := range []*IssueWatch{issueA, issueB} { + if err := store.CreateIssueWatch(t.Context(), watch); err != nil { + t.Fatalf("create issue watch: %v", err) + } + } + for _, row := range []struct { + watch *ReviewWatch + path string + iid int + task string + }{{reviewA, "a/project", 1, "review-a"}, {reviewB, "b/project", 2, "review-b"}} { + if _, err := store.ReserveReviewMRTask(t.Context(), row.watch.ID, row.path, row.iid, "url"); err != nil { + t.Fatal(err) + } + if err := store.AssignReviewMRTaskID(t.Context(), row.watch.ID, row.path, row.iid, row.task); err != nil { + t.Fatal(err) + } + } + for _, row := range []struct { + watch *IssueWatch + path string + iid int + task string + }{{issueA, "a/project", 3, "issue-a"}, {issueB, "b/project", 4, "issue-b"}} { + if _, err := store.ReserveIssueWatchTask(t.Context(), row.watch.ID, row.path, row.iid, "url"); err != nil { + t.Fatal(err) + } + if err := store.AssignIssueWatchTaskID(t.Context(), row.watch.ID, row.path, row.iid, row.task); err != nil { + t.Fatal(err) + } + } + + for _, path := range []string{"/cleanup/review-tasks", "/cleanup/issue-tasks"} { + response := watchScopeRequest(t, router, http.MethodPost, + "/api/v1/gitlab"+path+"?workspace_id=workspace-a", nil) + if response.Code != http.StatusOK { + t.Fatalf("%s status=%d body=%s", path, response.Code, response.Body.String()) + } + } + if len(deleter.ids) != 2 || deleter.ids[0] != "review-a" || deleter.ids[1] != "issue-a" { + t.Fatalf("deleted tasks=%v, want only workspace-a tasks", deleter.ids) + } +} + +func TestActionPresetUpdateUsesAuthoritativeWorkspaceQuery(t *testing.T) { + store, _, router := newWatchScopeFixture(t) + mr := []ActionPreset{{ID: "foreign", Label: "foreign", PromptTemplate: "review {{url}}"}} + response := watchScopeRequest(t, router, http.MethodPut, + "/api/v1/gitlab/action-presets?workspace_id=workspace-a", + UpdateActionPresetsRequest{WorkspaceID: "workspace-b", MR: &mr}) + if response.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } + a, err := store.GetActionPresets(t.Context(), "workspace-a") + if err != nil || len(a.MR) != 1 { + t.Fatalf("workspace-a presets=%+v err=%v", a, err) + } + b, err := store.GetActionPresets(t.Context(), "workspace-b") + if err != nil || len(b.MR) != 0 { + t.Fatalf("workspace-b was overwritten: presets=%+v err=%v", b, err) + } +} diff --git a/apps/backend/internal/gitlab/controller_watches.go b/apps/backend/internal/gitlab/controller_watches.go index 8b0cb1005b..faaa1735b3 100644 --- a/apps/backend/internal/gitlab/controller_watches.go +++ b/apps/backend/internal/gitlab/controller_watches.go @@ -15,10 +15,12 @@ func httpRespondError(ctx *gin.Context, err error) bool { return false } switch { + case errors.Is(err, ErrInvalidConfig): + ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "invalid watch configuration"}) case errors.Is(err, ErrWatchNotFound): - ctx.JSON(http.StatusNotFound, gin.H{responseErrorKey: err.Error()}) + ctx.JSON(http.StatusNotFound, gin.H{responseErrorKey: "watch not found"}) default: - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: "watch operation failed"}) } return true } @@ -28,6 +30,7 @@ func httpRespondError(ctx *gin.Context, err error) bool { // base set is registered. func (c *Controller) RegisterWatchHTTPRoutes(router *gin.Engine) { api := router.Group("/api/v1/gitlab") + api.Use(c.requireWorkspaceQuery) // MR / Review / Issue watches api.GET("/watches/mr", c.httpListMRWatches) @@ -36,15 +39,21 @@ func (c *Controller) RegisterWatchHTTPRoutes(router *gin.Engine) { api.GET("/watches/review", c.httpListReviewWatches) api.POST("/watches/review", c.httpCreateReviewWatch) api.PUT("/watches/review/:id", c.httpUpdateReviewWatch) + api.PATCH("/watches/review/:id", c.httpUpdateReviewWatch) api.DELETE("/watches/review/:id", c.httpDeleteReviewWatch) api.POST("/watches/review/:id/trigger", c.httpTriggerReviewWatch) + api.GET("/watches/review/:id/reset/preview", c.httpPreviewResetReviewWatch) + api.POST("/watches/review/:id/reset", c.httpResetReviewWatch) api.POST("/watches/review/trigger-all", c.httpTriggerAllReviewChecks) api.GET("/watches/issue", c.httpListIssueWatches) api.POST("/watches/issue", c.httpCreateIssueWatch) api.PUT("/watches/issue/:id", c.httpUpdateIssueWatch) + api.PATCH("/watches/issue/:id", c.httpUpdateIssueWatch) api.DELETE("/watches/issue/:id", c.httpDeleteIssueWatch) api.POST("/watches/issue/:id/trigger", c.httpTriggerIssueWatch) + api.GET("/watches/issue/:id/reset/preview", c.httpPreviewResetIssueWatch) + api.POST("/watches/issue/:id/reset", c.httpResetIssueWatch) api.POST("/watches/issue/trigger-all", c.httpTriggerAllIssueChecks) // Cleanup @@ -75,6 +84,14 @@ func (c *Controller) RegisterWatchHTTPRoutes(router *gin.Engine) { api.GET("/stats", c.httpGetStats) } +func (c *Controller) requireWorkspaceQuery(ctx *gin.Context) { + if c.workspaceID(ctx) == "" { + ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{responseErrorKey: "workspace_id query parameter required"}) + return + } + ctx.Next() +} + // --- Helpers --- func bindJSON[T any](ctx *gin.Context, out *T) bool { @@ -88,27 +105,28 @@ func bindJSON[T any](ctx *gin.Context, out *T) bool { // --- MR watches --- func (c *Controller) httpListMRWatches(ctx *gin.Context) { + workspaceID := c.workspaceID(ctx) sessionID := ctx.Query("session_id") taskID := ctx.Query("task_id") switch { case sessionID != "": - watches, err := c.service.ListMRWatchesBySession(ctx.Request.Context(), sessionID) + watches, err := c.service.ListMRWatchesBySessionForWorkspace(ctx.Request.Context(), workspaceID, sessionID) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: "watch operation failed"}) return } ctx.JSON(http.StatusOK, gin.H{"watches": watches}) case taskID != "": - watches, err := c.service.ListMRWatchesByTask(ctx.Request.Context(), taskID) + watches, err := c.service.ListMRWatchesByTaskForWorkspace(ctx.Request.Context(), workspaceID, taskID) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: "watch operation failed"}) return } ctx.JSON(http.StatusOK, gin.H{"watches": watches}) default: - watches, err := c.service.ListActiveMRWatches(ctx.Request.Context()) + watches, err := c.service.ListActiveMRWatchesForWorkspace(ctx.Request.Context(), workspaceID) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: "watch operation failed"}) return } ctx.JSON(http.StatusOK, gin.H{"watches": watches}) @@ -117,8 +135,12 @@ func (c *Controller) httpListMRWatches(ctx *gin.Context) { func (c *Controller) httpDeleteMRWatch(ctx *gin.Context) { id := ctx.Param("id") - if err := c.service.DeleteMRWatch(ctx.Request.Context(), id); err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + if err := c.service.DeleteMRWatchForWorkspace(ctx.Request.Context(), c.workspaceID(ctx), id); err != nil { + if errors.Is(err, ErrWatchNotFound) { + ctx.JSON(http.StatusNotFound, gin.H{responseErrorKey: "watch not found"}) + return + } + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: "watch operation failed"}) return } ctx.JSON(http.StatusOK, gin.H{"deleted": true}) @@ -127,18 +149,9 @@ func (c *Controller) httpDeleteMRWatch(ctx *gin.Context) { // --- Review watches --- func (c *Controller) httpListReviewWatches(ctx *gin.Context) { - wsID := ctx.Query("workspace_id") - var ( - watches []*ReviewWatch - err error - ) - if wsID == "" { - watches, err = c.service.ListAllReviewWatches(ctx.Request.Context()) - } else { - watches, err = c.service.ListReviewWatches(ctx.Request.Context(), wsID) - } + watches, err := c.service.ListReviewWatches(ctx.Request.Context(), c.workspaceID(ctx)) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: "watch operation failed"}) return } ctx.JSON(http.StatusOK, gin.H{"watches": watches}) @@ -149,9 +162,9 @@ func (c *Controller) httpCreateReviewWatch(ctx *gin.Context) { if !bindJSON(ctx, &req) { return } + req.WorkspaceID = c.workspaceID(ctx) rw, err := c.service.CreateReviewWatch(ctx.Request.Context(), &req) - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + if httpRespondError(ctx, err) { return } ctx.JSON(http.StatusOK, rw) @@ -159,6 +172,9 @@ func (c *Controller) httpCreateReviewWatch(ctx *gin.Context) { func (c *Controller) httpUpdateReviewWatch(ctx *gin.Context) { id := ctx.Param("id") + if !c.requireReviewWatchInWorkspace(ctx, id) { + return + } var req UpdateReviewWatchRequest if !bindJSON(ctx, &req) { return @@ -176,8 +192,11 @@ func (c *Controller) httpUpdateReviewWatch(ctx *gin.Context) { func (c *Controller) httpDeleteReviewWatch(ctx *gin.Context) { id := ctx.Param("id") + if !c.requireReviewWatchDeleteInWorkspace(ctx, id) { + return + } if err := c.service.DeleteReviewWatch(ctx.Request.Context(), id); err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: "watch operation failed"}) return } ctx.JSON(http.StatusOK, gin.H{"deleted": true}) @@ -185,6 +204,9 @@ func (c *Controller) httpDeleteReviewWatch(ctx *gin.Context) { func (c *Controller) httpTriggerReviewWatch(ctx *gin.Context) { id := ctx.Param("id") + if !c.requireReviewWatchInWorkspace(ctx, id) { + return + } mrs, err := c.service.TriggerReviewWatch(ctx.Request.Context(), id) if httpRespondError(ctx, err) { return @@ -193,9 +215,8 @@ func (c *Controller) httpTriggerReviewWatch(ctx *gin.Context) { } func (c *Controller) httpTriggerAllReviewChecks(ctx *gin.Context) { - n, err := c.service.TriggerReviewWatchAll(ctx.Request.Context()) - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + n, err := c.service.TriggerReviewWatchAllForWorkspace(ctx.Request.Context(), c.workspaceID(ctx)) + if httpRespondError(ctx, err) { return } ctx.JSON(http.StatusOK, gin.H{"count": n}) @@ -204,18 +225,9 @@ func (c *Controller) httpTriggerAllReviewChecks(ctx *gin.Context) { // --- Issue watches --- func (c *Controller) httpListIssueWatches(ctx *gin.Context) { - wsID := ctx.Query("workspace_id") - var ( - watches []*IssueWatch - err error - ) - if wsID == "" { - watches, err = c.service.ListAllIssueWatches(ctx.Request.Context()) - } else { - watches, err = c.service.ListIssueWatches(ctx.Request.Context(), wsID) - } + watches, err := c.service.ListIssueWatches(ctx.Request.Context(), c.workspaceID(ctx)) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: "watch operation failed"}) return } ctx.JSON(http.StatusOK, gin.H{"watches": watches}) @@ -226,9 +238,9 @@ func (c *Controller) httpCreateIssueWatch(ctx *gin.Context) { if !bindJSON(ctx, &req) { return } + req.WorkspaceID = c.workspaceID(ctx) iw, err := c.service.CreateIssueWatch(ctx.Request.Context(), &req) - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + if httpRespondError(ctx, err) { return } ctx.JSON(http.StatusOK, iw) @@ -236,6 +248,9 @@ func (c *Controller) httpCreateIssueWatch(ctx *gin.Context) { func (c *Controller) httpUpdateIssueWatch(ctx *gin.Context) { id := ctx.Param("id") + if !c.requireIssueWatchInWorkspace(ctx, id) { + return + } var req UpdateIssueWatchRequest if !bindJSON(ctx, &req) { return @@ -253,8 +268,11 @@ func (c *Controller) httpUpdateIssueWatch(ctx *gin.Context) { func (c *Controller) httpDeleteIssueWatch(ctx *gin.Context) { id := ctx.Param("id") + if !c.requireIssueWatchDeleteInWorkspace(ctx, id) { + return + } if err := c.service.DeleteIssueWatch(ctx.Request.Context(), id); err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: "watch operation failed"}) return } ctx.JSON(http.StatusOK, gin.H{"deleted": true}) @@ -262,6 +280,9 @@ func (c *Controller) httpDeleteIssueWatch(ctx *gin.Context) { func (c *Controller) httpTriggerIssueWatch(ctx *gin.Context) { id := ctx.Param("id") + if !c.requireIssueWatchInWorkspace(ctx, id) { + return + } issues, err := c.service.TriggerIssueWatch(ctx.Request.Context(), id) if httpRespondError(ctx, err) { return @@ -270,9 +291,8 @@ func (c *Controller) httpTriggerIssueWatch(ctx *gin.Context) { } func (c *Controller) httpTriggerAllIssueChecks(ctx *gin.Context) { - n, err := c.service.TriggerIssueWatchAll(ctx.Request.Context()) - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + n, err := c.service.TriggerIssueWatchAllForWorkspace(ctx.Request.Context(), c.workspaceID(ctx)) + if httpRespondError(ctx, err) { return } ctx.JSON(http.StatusOK, gin.H{"count": n}) @@ -281,18 +301,18 @@ func (c *Controller) httpTriggerAllIssueChecks(ctx *gin.Context) { // --- Cleanup --- func (c *Controller) httpCleanupReviewTasks(ctx *gin.Context) { - n, err := c.service.CleanupAllReviewTasks(ctx.Request.Context()) + n, err := c.service.CleanupReviewTasksForWorkspace(ctx.Request.Context(), c.workspaceID(ctx)) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: "cleanup operation failed"}) return } ctx.JSON(http.StatusOK, gin.H{"deleted": n}) } func (c *Controller) httpCleanupIssueTasks(ctx *gin.Context) { - n, err := c.service.CleanupAllIssueTasks(ctx.Request.Context()) + n, err := c.service.CleanupIssueTasksForWorkspace(ctx.Request.Context(), c.workspaceID(ctx)) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: "cleanup operation failed"}) return } ctx.JSON(http.StatusOK, gin.H{"deleted": n}) @@ -301,9 +321,13 @@ func (c *Controller) httpCleanupIssueTasks(ctx *gin.Context) { // --- Projects --- func (c *Controller) httpListUserProjects(ctx *gin.Context) { - projects, err := c.service.ListUserProjects(ctx.Request.Context()) + client, ok := c.workspaceClient(ctx) + if !ok { + return + } + projects, err := client.ListUserProjects(ctx.Request.Context()) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + writeWorkspaceClientActionError(ctx, err, "project list") return } ctx.JSON(http.StatusOK, gin.H{"projects": projects}) @@ -311,9 +335,13 @@ func (c *Controller) httpListUserProjects(ctx *gin.Context) { func (c *Controller) httpSearchProjects(ctx *gin.Context) { query := ctx.Query("query") - projects, err := c.service.SearchProjects(ctx.Request.Context(), query, 50) + client, ok := c.workspaceClient(ctx) + if !ok { + return + } + projects, err := client.SearchProjects(ctx.Request.Context(), query, 50) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + writeWorkspaceClientActionError(ctx, err, "project search") return } ctx.JSON(http.StatusOK, gin.H{"projects": projects}) @@ -325,9 +353,13 @@ func (c *Controller) httpListProjectBranches(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "project required"}) return } - branches, err := c.service.ListProjectBranches(ctx.Request.Context(), project) + client, ok := c.workspaceClient(ctx) + if !ok { + return + } + branches, err := client.ListProjectBranches(ctx.Request.Context(), project) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + writeWorkspaceClientActionError(ctx, err, "project branches") return } ctx.JSON(http.StatusOK, gin.H{"branches": branches}) @@ -339,9 +371,13 @@ func (c *Controller) httpGetProjectMergeMethods(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "project required"}) return } - methods, err := c.service.GetProjectMergeMethods(ctx.Request.Context(), project) + client, ok := c.workspaceClient(ctx) + if !ok { + return + } + methods, err := client.GetProjectMergeMethods(ctx.Request.Context(), project) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + writeWorkspaceClientActionError(ctx, err, "project merge methods") return } ctx.JSON(http.StatusOK, methods) @@ -361,9 +397,9 @@ func (c *Controller) httpMergeMR(ctx *gin.Context) { if !bindJSON(ctx, &req) { return } - mr, err := c.service.MergeMR(ctx.Request.Context(), req.Project, req.IID, req.Method, req.SquashCommitMessage) + mr, err := c.service.MergeMRForWorkspaceHost(ctx.Request.Context(), c.workspaceID(ctx), ctx.Query("expected_host"), req.Project, req.IID, req.Method, req.SquashCommitMessage) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + writeWorkspaceClientActionError(ctx, err, "merge request merge") return } ctx.JSON(http.StatusOK, mr) @@ -379,8 +415,11 @@ func (c *Controller) httpApproveMR(ctx *gin.Context) { if !bindJSON(ctx, &req) { return } - if err := c.service.SubmitMRApproval(ctx.Request.Context(), req.Project, req.IID); err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + err := c.runWorkspaceClientAction(ctx, func(client Client) error { + return client.SubmitMRApproval(ctx.Request.Context(), req.Project, req.IID) + }) + if err != nil { + writeWorkspaceClientActionError(ctx, err, "approval update") return } ctx.JSON(http.StatusOK, gin.H{"approved": true}) @@ -391,8 +430,11 @@ func (c *Controller) httpUnapproveMR(ctx *gin.Context) { if !bindJSON(ctx, &req) { return } - if err := c.service.SubmitMRUnapproval(ctx.Request.Context(), req.Project, req.IID); err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + err := c.runWorkspaceClientAction(ctx, func(client Client) error { + return client.SubmitMRUnapproval(ctx.Request.Context(), req.Project, req.IID) + }) + if err != nil { + writeWorkspaceClientActionError(ctx, err, "approval update") return } ctx.JSON(http.StatusOK, gin.H{"unapproved": true}) @@ -409,8 +451,11 @@ func (c *Controller) httpSetMRLabels(ctx *gin.Context) { if !bindJSON(ctx, &req) { return } - if err := c.service.SetMRLabels(ctx.Request.Context(), req.Project, req.IID, req.Labels); err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + err := c.runWorkspaceClientAction(ctx, func(client Client) error { + return client.SetMRLabels(ctx.Request.Context(), req.Project, req.IID, req.Labels) + }) + if err != nil { + writeWorkspaceClientActionError(ctx, err, "label update") return } ctx.JSON(http.StatusOK, gin.H{"updated": true}) @@ -427,8 +472,11 @@ func (c *Controller) httpSetMRAssignees(ctx *gin.Context) { if !bindJSON(ctx, &req) { return } - if err := c.service.SetMRAssignees(ctx.Request.Context(), req.Project, req.IID, req.AssigneeIDs); err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + err := c.runWorkspaceClientAction(ctx, func(client Client) error { + return client.SetMRAssignees(ctx.Request.Context(), req.Project, req.IID, req.AssigneeIDs) + }) + if err != nil { + writeWorkspaceClientActionError(ctx, err, "assignee update") return } ctx.JSON(http.StatusOK, gin.H{"updated": true}) @@ -440,9 +488,14 @@ func (c *Controller) httpGetMRFiles(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: err.Error()}) return } - files, err := c.service.GetMRFiles(ctx.Request.Context(), project, iid) + var files []MRFile + err = c.runWorkspaceClientAction(ctx, func(client Client) error { + var actionErr error + files, actionErr = client.ListMRFiles(ctx.Request.Context(), project, iid) + return actionErr + }) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + writeWorkspaceClientActionError(ctx, err, "merge request files") return } ctx.JSON(http.StatusOK, gin.H{"files": files}) @@ -454,9 +507,14 @@ func (c *Controller) httpGetMRCommits(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: err.Error()}) return } - commits, err := c.service.GetMRCommits(ctx.Request.Context(), project, iid) + var commits []MRCommitInfo + err = c.runWorkspaceClientAction(ctx, func(client Client) error { + var actionErr error + commits, actionErr = client.ListMRCommits(ctx.Request.Context(), project, iid) + return actionErr + }) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + writeWorkspaceClientActionError(ctx, err, "merge request commits") return } ctx.JSON(http.StatusOK, gin.H{"commits": commits}) @@ -465,14 +523,10 @@ func (c *Controller) httpGetMRCommits(ctx *gin.Context) { // --- Action presets --- func (c *Controller) httpGetActionPresets(ctx *gin.Context) { - wsID := ctx.Query("workspace_id") - if wsID == "" { - ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "workspace_id required"}) - return - } + wsID := c.workspaceID(ctx) presets, err := c.service.GetActionPresetsOrDefault(ctx.Request.Context(), wsID) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: "action presets unavailable"}) return } ctx.JSON(http.StatusOK, presets) @@ -483,27 +537,20 @@ func (c *Controller) httpUpdateActionPresets(ctx *gin.Context) { if !bindJSON(ctx, &req) { return } - if req.WorkspaceID == "" { - ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "workspace_id required"}) - return - } + req.WorkspaceID = c.workspaceID(ctx) presets, err := c.service.UpdateActionPresets(ctx.Request.Context(), &req) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: "action preset update failed"}) return } ctx.JSON(http.StatusOK, presets) } func (c *Controller) httpResetActionPresets(ctx *gin.Context) { - wsID := ctx.Query("workspace_id") - if wsID == "" { - ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "workspace_id required"}) - return - } + wsID := c.workspaceID(ctx) presets, err := c.service.ResetActionPresets(ctx.Request.Context(), wsID) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: "action preset reset failed"}) return } ctx.JSON(http.StatusOK, presets) @@ -512,9 +559,9 @@ func (c *Controller) httpResetActionPresets(ctx *gin.Context) { // --- Stats --- func (c *Controller) httpGetStats(ctx *gin.Context) { - stats, err := c.service.GetStats(ctx.Request.Context()) + stats, err := c.service.GetStatsForWorkspace(ctx.Request.Context(), c.workspaceID(ctx)) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + writeWorkspaceClientActionError(ctx, err, "statistics") return } ctx.JSON(http.StatusOK, stats) diff --git a/apps/backend/internal/gitlab/copy.go b/apps/backend/internal/gitlab/copy.go new file mode 100644 index 0000000000..74101c478e --- /dev/null +++ b/apps/backend/internal/gitlab/copy.go @@ -0,0 +1,59 @@ +package gitlab + +import ( + "context" + "errors" + "fmt" + "strings" +) + +var ErrSameWorkspace = errors.New("gitlab: source and target workspaces are the same") +var ErrNothingToCopy = errors.New("gitlab: source workspace has no configuration to copy") + +// CopyConfigToWorkspace copies connection metadata and a stored PAT only. +func (s *Service) CopyConfigToWorkspace(ctx context.Context, sourceWorkspaceID, targetWorkspaceID string) (*GitLabConfig, error) { + sourceWorkspaceID = strings.TrimSpace(sourceWorkspaceID) + targetWorkspaceID = strings.TrimSpace(targetWorkspaceID) + if sourceWorkspaceID == "" || targetWorkspaceID == "" { + return nil, ErrWorkspaceRequired + } + if sourceWorkspaceID == targetWorkspaceID { + return nil, ErrSameWorkspace + } + if err := s.copyConfigLocked(ctx, sourceWorkspaceID, targetWorkspaceID); err != nil { + return nil, err + } + return s.GetConfigForWorkspace(ctx, targetWorkspaceID) +} + +func (s *Service) copyConfigLocked(ctx context.Context, sourceWorkspaceID, targetWorkspaceID string) error { + s.configMutationMu.Lock() + defer s.configMutationMu.Unlock() + + s.mu.RLock() + store := s.store + secrets := s.workspaceSecrets + s.mu.RUnlock() + if store == nil { + return errors.New("gitlab store not configured") + } + source, err := snapshotWorkspaceConnection(ctx, store, secrets, sourceWorkspaceID) + if err != nil { + return err + } + if source.config == nil { + return ErrNothingToCopy + } + token := "" + if source.config.AuthMethod == AuthMethodPAT { + if !source.secret.exists || strings.TrimSpace(source.secret.value) == "" { + return ErrNothingToCopy + } + token = source.secret.value + } + copyConfig := &GitLabConfig{Host: source.config.Host, AuthMethod: source.config.AuthMethod, Username: source.config.Username} + if err := s.persistWorkspaceConfigLocked(ctx, store, secrets, targetWorkspaceID, copyConfig, token); err != nil { + return fmt.Errorf("copy GitLab config: %w", err) + } + return nil +} diff --git a/apps/backend/internal/gitlab/glab_client_test.go b/apps/backend/internal/gitlab/glab_client_test.go index 4114dc115a..dc417841dd 100644 --- a/apps/backend/internal/gitlab/glab_client_test.go +++ b/apps/backend/internal/gitlab/glab_client_test.go @@ -2,6 +2,10 @@ package gitlab import "testing" +func TestGLabClientSatisfiesProviderActionContract(t *testing.T) { + var _ Client = (*GLabClient)(nil) +} + func TestParseGlabToken_ExtractsValue(t *testing.T) { output := `Logged in to gitlab.com as alice (oauth_token) ✓ Token: glpat-AAAAA-BBBBB diff --git a/apps/backend/internal/gitlab/goleak_test.go b/apps/backend/internal/gitlab/goleak_test.go new file mode 100644 index 0000000000..ff9dfb1362 --- /dev/null +++ b/apps/backend/internal/gitlab/goleak_test.go @@ -0,0 +1,11 @@ +package gitlab + +import ( + "testing" + + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} diff --git a/apps/backend/internal/gitlab/handlers.go b/apps/backend/internal/gitlab/handlers.go index 72aecd7e5c..8f27592933 100644 --- a/apps/backend/internal/gitlab/handlers.go +++ b/apps/backend/internal/gitlab/handlers.go @@ -21,9 +21,10 @@ const ( // adding a sibling function so callers can pass a dispatcher. func RegisterRoutesWithDispatcher(router *gin.Engine, dispatcher *ws.Dispatcher, svc *Service, log *logger.Logger) { NewController(svc, log).RegisterHTTPRoutes(router) - if dispatcher != nil { - registerWSHandlers(dispatcher, svc, log) - } + // GitLab requests are workspace-scoped on the HTTP surface. The legacy + // WebSocket handlers predate workspace-owned connections and cannot carry + // the same authorization boundary, so they intentionally remain unmounted. + _ = dispatcher } // RegisterMockRoutes registers mock control endpoints if the underlying diff --git a/apps/backend/internal/gitlab/mock_client.go b/apps/backend/internal/gitlab/mock_client.go index 4db9214641..81418b5b1b 100644 --- a/apps/backend/internal/gitlab/mock_client.go +++ b/apps/backend/internal/gitlab/mock_client.go @@ -8,6 +8,8 @@ import ( "time" ) +const mockDefaultUsername = "kandev-tester" + // MockClient is an in-memory GitLab client used by E2E tests. // Activated when KANDEV_MOCK_GITLAB=true. It serves a small fixed set of // MRs/issues/discussions plus accepts dynamic seeding via mock_controller. @@ -22,6 +24,8 @@ type MockClient struct { username string mrs map[mockMRKey]*MR discussions map[mockMRKey][]MRDiscussion + files map[mockMRKey][]MRFile + commits map[mockMRKey][]MRCommitInfo // pipelines is keyed by project — ListPipelines below returns the // project's seeded set regardless of branch or iid, matching the // real PATClient.ListPipelines flow (one MR head ref → all @@ -33,11 +37,14 @@ type MockClient struct { // approved_by list. Both are seeded separately because the GitLab // /approvals endpoint conflates them on one payload — the mock keeps // them split so tests can express e.g. "2 approvals required, 1 given". - approvals map[mockMRKey][]MRApproval - requiredApprovals map[mockMRKey]int - issues map[mockIssueKey]*Issue - branches map[string][]RepoBranch - nextMRIID int + approvals map[mockMRKey][]MRApproval + requiredApprovals map[mockMRKey]int + issues map[mockIssueKey]*Issue + branches map[string][]RepoBranch + members map[string][]ProjectMember + mrSubscriptions map[mockMRKey]bool + issueSubscriptions map[mockIssueKey]bool + nextMRIID int } type mockMRKey struct { @@ -55,21 +62,43 @@ func NewMockClient(host string) *MockClient { if host == "" { host = DefaultHost } - c := &MockClient{ - host: host, - username: "kandev-tester", - mrs: make(map[mockMRKey]*MR), - discussions: make(map[mockMRKey][]MRDiscussion), - pipelines: make(map[string][]Pipeline), - approvals: make(map[mockMRKey][]MRApproval), - requiredApprovals: make(map[mockMRKey]int), - issues: make(map[mockIssueKey]*Issue), - branches: make(map[string][]RepoBranch), - nextMRIID: 100, - } + c := &MockClient{host: host} + c.resetLocked() return c } +// Reset clears all provider state so consecutive E2E tests sharing one +// backend worker cannot observe each other's seeded or mutated GitLab data. +func (c *MockClient) Reset() { + c.mu.Lock() + defer c.mu.Unlock() + c.resetLocked() +} + +func (c *MockClient) resetLocked() { + c.username = mockDefaultUsername + c.mrs = make(map[mockMRKey]*MR) + c.discussions = make(map[mockMRKey][]MRDiscussion) + c.files = make(map[mockMRKey][]MRFile) + c.commits = make(map[mockMRKey][]MRCommitInfo) + c.pipelines = make(map[string][]Pipeline) + c.approvals = make(map[mockMRKey][]MRApproval) + c.requiredApprovals = make(map[mockMRKey]int) + c.issues = make(map[mockIssueKey]*Issue) + c.branches = make(map[string][]RepoBranch) + c.members = make(map[string][]ProjectMember) + c.mrSubscriptions = make(map[mockMRKey]bool) + c.issueSubscriptions = make(map[mockIssueKey]bool) + c.nextMRIID = 100 +} + +// SeedProjectMembers sets the reviewer candidates for a project. +func (c *MockClient) SeedProjectMembers(projectPath string, members []ProjectMember) { + c.mu.Lock() + defer c.mu.Unlock() + c.members[projectPath] = append([]ProjectMember(nil), members...) +} + // SetUser overrides the authenticated user reported by the mock. func (c *MockClient) SetUser(username string) { c.mu.Lock() @@ -100,6 +129,20 @@ func (c *MockClient) SeedDiscussions(projectPath string, iid int, discussions [] c.discussions[mockMRKey{Project: projectPath, IID: iid}] = discussions } +// SeedFiles sets the file changes returned for an MR. +func (c *MockClient) SeedFiles(projectPath string, iid int, files []MRFile) { + c.mu.Lock() + defer c.mu.Unlock() + c.files[mockMRKey{Project: projectPath, IID: iid}] = append([]MRFile(nil), files...) +} + +// SeedCommits sets the commits returned for an MR. +func (c *MockClient) SeedCommits(projectPath string, iid int, commits []MRCommitInfo) { + c.mu.Lock() + defer c.mu.Unlock() + c.commits[mockMRKey{Project: projectPath, IID: iid}] = append([]MRCommitInfo(nil), commits...) +} + // SeedIssue registers an issue. func (c *MockClient) SeedIssue(projectPath string, issue *Issue) { c.mu.Lock() @@ -319,16 +362,54 @@ func (c *MockClient) GetMRStatus(ctx context.Context, projectPath string, iid in }, nil } -func (c *MockClient) ListMRFiles(context.Context, string, int) ([]MRFile, error) { - return []MRFile{}, nil +func (c *MockClient) ListMRFiles(_ context.Context, projectPath string, iid int) ([]MRFile, error) { + c.mu.Lock() + defer c.mu.Unlock() + return append([]MRFile(nil), c.files[mockMRKey{Project: projectPath, IID: iid}]...), nil } -func (c *MockClient) ListMRCommits(context.Context, string, int) ([]MRCommitInfo, error) { - return []MRCommitInfo{}, nil +func (c *MockClient) ListMRCommits(_ context.Context, projectPath string, iid int) ([]MRCommitInfo, error) { + c.mu.Lock() + defer c.mu.Unlock() + return append([]MRCommitInfo(nil), c.commits[mockMRKey{Project: projectPath, IID: iid}]...), nil } -func (c *MockClient) SubmitMRApproval(context.Context, string, int) error { return nil } -func (c *MockClient) SubmitMRUnapproval(context.Context, string, int) error { return nil } +func (c *MockClient) SubmitMRApproval(_ context.Context, projectPath string, iid int) error { + c.mu.Lock() + defer c.mu.Unlock() + key := mockMRKey{Project: projectPath, IID: iid} + if _, ok := c.mrs[key]; !ok { + return fmt.Errorf("mock: MR %s!%d not found", projectPath, iid) + } + for _, approval := range c.approvals[key] { + if approval.Username == c.username { + return nil + } + } + c.approvals[key] = append(c.approvals[key], MRApproval{ + Username: c.username, + CreatedAt: time.Now().UTC(), + }) + return nil +} + +func (c *MockClient) SubmitMRUnapproval(_ context.Context, projectPath string, iid int) error { + c.mu.Lock() + defer c.mu.Unlock() + key := mockMRKey{Project: projectPath, IID: iid} + if _, ok := c.mrs[key]; !ok { + return fmt.Errorf("mock: MR %s!%d not found", projectPath, iid) + } + approvals := c.approvals[key] + filtered := approvals[:0] + for _, approval := range approvals { + if approval.Username != c.username { + filtered = append(filtered, approval) + } + } + c.approvals[key] = filtered + return nil +} func (c *MockClient) CreateMR(_ context.Context, projectPath, sourceBranch, targetBranch, title, description string, draft bool) (*MR, error) { c.mu.Lock() @@ -448,11 +529,109 @@ func (c *MockClient) SearchProjects(ctx context.Context, query string, _ int) ([ return out, nil } -// SetMRLabels is a no-op in the mock (seeded MRs are accepted verbatim). -func (c *MockClient) SetMRLabels(context.Context, string, int, []string) error { return nil } +func (c *MockClient) SetMRLabels(_ context.Context, projectPath string, iid int, labels []string) error { + c.mu.Lock() + defer c.mu.Unlock() + mr, ok := c.mrs[mockMRKey{Project: projectPath, IID: iid}] + if !ok { + return fmt.Errorf("mock: MR %s!%d not found", projectPath, iid) + } + mr.Labels = append([]string(nil), labels...) + return nil +} -// SetMRAssignees is a no-op in the mock. -func (c *MockClient) SetMRAssignees(context.Context, string, int, []int) error { return nil } +func (c *MockClient) SetMRAssignees(_ context.Context, projectPath string, iid int, assigneeIDs []int) error { + c.mu.Lock() + defer c.mu.Unlock() + mr, ok := c.mrs[mockMRKey{Project: projectPath, IID: iid}] + if !ok { + return fmt.Errorf("mock: MR %s!%d not found", projectPath, iid) + } + assignees := make([]MRReviewer, 0, len(assigneeIDs)) + for _, assigneeID := range assigneeIDs { + found := false + for _, member := range c.members[projectPath] { + if member.ID == int64(assigneeID) { + assignees = append(assignees, MRReviewer{ + ID: member.ID, Username: member.Username, Name: member.Name, Type: "user", + }) + found = true + break + } + } + if !found { + return fmt.Errorf("mock: project member %d not found", assigneeID) + } + } + mr.Assignees = assignees + return nil +} + +func (c *MockClient) ListProjectMembers(_ context.Context, projectPath, query string) ([]ProjectMember, error) { + c.mu.Lock() + defer c.mu.Unlock() + needle := strings.ToLower(strings.TrimSpace(query)) + members := make([]ProjectMember, 0, len(c.members[projectPath])) + for _, member := range c.members[projectPath] { + if needle != "" && !strings.Contains(strings.ToLower(member.Username), needle) && + !strings.Contains(strings.ToLower(member.Name), needle) { + continue + } + members = append(members, member) + } + return members, nil +} + +func (c *MockClient) SetMRReviewers(_ context.Context, projectPath string, iid int, reviewerIDs []int64) error { + c.mu.Lock() + defer c.mu.Unlock() + mr, ok := c.mrs[mockMRKey{Project: projectPath, IID: iid}] + if !ok { + return fmt.Errorf("mock: MR %s!%d not found", projectPath, iid) + } + reviewers := make([]MRReviewer, 0, len(reviewerIDs)) + for _, reviewerID := range reviewerIDs { + found := false + for _, member := range c.members[projectPath] { + if member.ID == reviewerID { + reviewers = append(reviewers, MRReviewer{ID: member.ID, Username: member.Username, Name: member.Name, Type: "user"}) + found = true + break + } + } + if !found { + return fmt.Errorf("mock: project member %d not found", reviewerID) + } + } + mr.Reviewers = reviewers + return nil +} + +func (c *MockClient) GetMRSubscription(_ context.Context, projectPath string, iid int) (*SubscriptionState, error) { + c.mu.Lock() + defer c.mu.Unlock() + return &SubscriptionState{Subscribed: c.mrSubscriptions[mockMRKey{Project: projectPath, IID: iid}]}, nil +} + +func (c *MockClient) SetMRSubscription(_ context.Context, projectPath string, iid int, subscribed bool) (*SubscriptionState, error) { + c.mu.Lock() + defer c.mu.Unlock() + c.mrSubscriptions[mockMRKey{Project: projectPath, IID: iid}] = subscribed + return &SubscriptionState{Subscribed: subscribed}, nil +} + +func (c *MockClient) GetIssueSubscription(_ context.Context, projectPath string, iid int) (*SubscriptionState, error) { + c.mu.Lock() + defer c.mu.Unlock() + return &SubscriptionState{Subscribed: c.issueSubscriptions[mockIssueKey{Project: projectPath, IID: iid}]}, nil +} + +func (c *MockClient) SetIssueSubscription(_ context.Context, projectPath string, iid int, subscribed bool) (*SubscriptionState, error) { + c.mu.Lock() + defer c.mu.Unlock() + c.issueSubscriptions[mockIssueKey{Project: projectPath, IID: iid}] = subscribed + return &SubscriptionState{Subscribed: subscribed}, nil +} // Stats returns a summary of the seeded data, useful for E2E assertions. func (c *MockClient) Stats() string { diff --git a/apps/backend/internal/gitlab/mock_client_test.go b/apps/backend/internal/gitlab/mock_client_test.go index 30e581019c..69782e583c 100644 --- a/apps/backend/internal/gitlab/mock_client_test.go +++ b/apps/backend/internal/gitlab/mock_client_test.go @@ -5,6 +5,73 @@ import ( "testing" ) +func TestMockClientReviewerAndSubscriptionActions(t *testing.T) { + var _ Client = (*MockClient)(nil) + const project = "team/repo" + mock := NewMockClient("") + mock.SeedProjectMembers(project, []ProjectMember{ + {ID: 42, Username: "alice", Name: "Alice"}, + {ID: 91, Username: "bob", Name: "Bob"}, + }) + mock.SeedMR(project, &MR{IID: 7, State: mrStateOpen}) + mock.SeedIssue(project, &Issue{IID: 8, State: gitlabStateOpened}) + + members, err := mock.ListProjectMembers(t.Context(), project, "ali") + if err != nil || len(members) != 1 || members[0].ID != 42 { + t.Fatalf("members = %#v, err=%v", members, err) + } + if err := mock.SetMRReviewers(t.Context(), project, 7, []int64{42}); err != nil { + t.Fatalf("set reviewers: %v", err) + } + mr, err := mock.GetMR(t.Context(), project, 7) + if err != nil || len(mr.Reviewers) != 1 || mr.Reviewers[0].ID != 42 { + t.Fatalf("MR reviewers = %#v, err=%v", mr.Reviewers, err) + } + if err := mock.SetMRReviewers(t.Context(), project, 7, []int64{}); err != nil { + t.Fatalf("clear reviewers: %v", err) + } + if len(mr.Reviewers) != 0 { + t.Fatalf("reviewers after clear = %#v", mr.Reviewers) + } + + mrState, err := mock.SetMRSubscription(t.Context(), project, 7, true) + if err != nil || !mrState.Subscribed { + t.Fatalf("MR subscription = %#v, err=%v", mrState, err) + } + issueState, err := mock.SetIssueSubscription(t.Context(), project, 8, true) + if err != nil || !issueState.Subscribed { + t.Fatalf("issue subscription = %#v, err=%v", issueState, err) + } +} + +func TestMockClientRejectsIneligibleReviewerID(t *testing.T) { + mock := NewMockClient("") + mr := &MR{IID: 7} + mock.SeedMR("team/repo", mr) + if err := mock.SetMRReviewers(t.Context(), "team/repo", 7, []int64{404}); err == nil { + t.Fatal("set reviewers err = nil, want ineligible member error") + } + if len(mr.Reviewers) != 0 { + t.Fatalf("reviewers changed after rejected update: %#v", mr.Reviewers) + } +} + +func TestMockClientSetMRLabelsReplacesAndFeedbackHydrates(t *testing.T) { + mock := NewMockClient("") + const project = "team/repo" + mock.SeedMR(project, &MR{IID: 7, Labels: []string{"old"}}) + if err := mock.SetMRLabels(t.Context(), project, 7, []string{"bug", "backend"}); err != nil { + t.Fatalf("set labels: %v", err) + } + feedback, err := mock.GetMRFeedback(t.Context(), project, 7) + if err != nil { + t.Fatalf("feedback: %v", err) + } + if got := feedback.MR.Labels; len(got) != 2 || got[0] != "bug" || got[1] != "backend" { + t.Fatalf("labels = %#v, want replacement", got) + } +} + // MockClient.ListPipelines is keyed by project only — it returns every // pipeline seeded under the project regardless of the branch argument. Without // the head-ref guard in GetMRFeedback that means a brand-new MR with no head @@ -92,3 +159,78 @@ func TestMockClient_GetMRFeedback_ReportsPipelinesWhenHeadPresent(t *testing.T) t.Error("HasIssues = false despite a failing pipeline; want true") } } + +func TestMockClientReviewActionsMutateVisibleState(t *testing.T) { + mock := NewMockClient("https://gitlab.example.com") + const project = "team/repo" + mock.SetUser("reviewer") + mock.SeedProjectMembers(project, []ProjectMember{{ID: 17, Username: "owner", Name: "Owner"}}) + mock.SeedMR(project, &MR{IID: 7, State: mrStateOpen}) + + if err := mock.SubmitMRApproval(t.Context(), project, 7); err != nil { + t.Fatalf("approve: %v", err) + } + approvals, err := mock.ListMRApprovals(t.Context(), project, 7) + if err != nil || len(approvals) != 1 || approvals[0].Username != "reviewer" { + t.Fatalf("approvals = %#v, err=%v", approvals, err) + } + if err := mock.SubmitMRUnapproval(t.Context(), project, 7); err != nil { + t.Fatalf("unapprove: %v", err) + } + approvals, err = mock.ListMRApprovals(t.Context(), project, 7) + if err != nil || len(approvals) != 0 { + t.Fatalf("approvals after unapprove = %#v, err=%v", approvals, err) + } + + if err := mock.SetMRAssignees(t.Context(), project, 7, []int{17}); err != nil { + t.Fatalf("set assignees: %v", err) + } + mr, err := mock.GetMR(t.Context(), project, 7) + if err != nil || len(mr.Assignees) != 1 || mr.Assignees[0].ID != 17 { + t.Fatalf("assignees = %#v, err=%v", mr.Assignees, err) + } +} + +func TestMockClientSeedsFilesCommitsAndResetClearsAllState(t *testing.T) { + mock := NewMockClient("https://gitlab.example.com") + const project = "team/repo" + mock.SetUser("changed-user") + mock.SeedProjectMembers(project, []ProjectMember{{ID: 17, Username: "owner"}}) + mock.SeedMR(project, &MR{IID: 7, State: mrStateOpen}) + mock.SeedIssue(project, &Issue{IID: 8, State: gitlabStateOpened}) + mock.SeedFiles(project, 7, []MRFile{{Filename: "main.go", Status: "modified"}}) + mock.SeedCommits(project, 7, []MRCommitInfo{{SHA: "abc", Message: "change"}}) + mock.SeedDiscussions(project, 7, []MRDiscussion{{ID: "thread-1"}}) + mock.SeedPipelines(project, []Pipeline{{ID: 9, Status: "success"}}) + mock.SeedApprovals(project, 7, []MRApproval{{Username: "owner"}}, 1) + mock.SeedBranches(project, []RepoBranch{{Name: "main"}}) + _, _ = mock.SetMRSubscription(t.Context(), project, 7, true) + _, _ = mock.SetIssueSubscription(t.Context(), project, 8, true) + + files, err := mock.ListMRFiles(t.Context(), project, 7) + if err != nil || len(files) != 1 || files[0].Filename != "main.go" { + t.Fatalf("files = %#v, err=%v", files, err) + } + commits, err := mock.ListMRCommits(t.Context(), project, 7) + if err != nil || len(commits) != 1 || commits[0].SHA != "abc" { + t.Fatalf("commits = %#v, err=%v", commits, err) + } + + mock.Reset() + if got := mock.Stats(); got != "mrs=0 discussions=0 issues=0" { + t.Fatalf("stats after reset = %q", got) + } + if user, _ := mock.GetAuthenticatedUser(t.Context()); user != "kandev-tester" { + t.Fatalf("user after reset = %q", user) + } + created, err := mock.CreateMR(t.Context(), project, "feature", "main", "Fresh", "", false) + if err != nil || created.IID != 100 { + t.Fatalf("created after reset = %#v, err=%v", created, err) + } + if state, _ := mock.GetMRSubscription(t.Context(), project, 7); state.Subscribed { + t.Fatal("MR subscription survived reset") + } + if state, _ := mock.GetIssueSubscription(t.Context(), project, 8); state.Subscribed { + t.Fatal("issue subscription survived reset") + } +} diff --git a/apps/backend/internal/gitlab/mock_controller.go b/apps/backend/internal/gitlab/mock_controller.go index 82ad68c0e8..84f42cf0d2 100644 --- a/apps/backend/internal/gitlab/mock_controller.go +++ b/apps/backend/internal/gitlab/mock_controller.go @@ -1,13 +1,18 @@ package gitlab import ( + "errors" "net/http" + "net/url" + "strings" "github.com/gin-gonic/gin" "github.com/kandev/kandev/internal/common/logger" ) +const mockWorkspaceTokenPrefix = "e2e-gitlab:" + // MockController exposes HTTP endpoints to seed the in-memory MockClient. // Activated only when KANDEV_MOCK_GITLAB=true. type MockController struct { @@ -31,6 +36,106 @@ func (c *MockController) RegisterRoutes(router *gin.Engine) { api.POST("/discussions", c.seedDiscussions) api.POST("/approvals", c.seedApprovals) api.POST("/branches", c.seedBranches) + api.POST("/members", c.seedMembers) + api.POST("/files", c.seedFiles) + api.POST("/commits", c.seedCommits) + api.DELETE("/reset", c.reset) + + // The agentctl GitLab creator uses the upstream REST v4 shape. Keeping + // this route in the mock controller lets Playwright exercise that real + // code path without an external GitLab instance. + router.Any("/api/v4/projects/*path", c.gitLabAPI) +} + +type mockCreateMRRequest struct { + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + Title string `json:"title"` + Description string `json:"description"` +} + +func (c *MockController) gitLabAPI(ctx *gin.Context) { + workspaceID := strings.TrimPrefix(ctx.GetHeader("PRIVATE-TOKEN"), mockWorkspaceTokenPrefix) + if workspaceID == "" || workspaceID == ctx.GetHeader("PRIVATE-TOKEN") { + ctx.JSON(http.StatusUnauthorized, gin.H{"message": "invalid mock GitLab token"}) + return + } + client, err := c.service.ClientForWorkspace(ctx.Request.Context(), workspaceID) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"message": "unknown mock GitLab workspace"}) + return + } + mock, ok := client.(*MockClient) + if !ok { + ctx.JSON(http.StatusServiceUnavailable, gin.H{"message": "mock GitLab client unavailable"}) + return + } + + rawPath := strings.TrimPrefix(ctx.Param("path"), "/") + decodedPath, decodeErr := url.PathUnescape(rawPath) + if decodeErr != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"message": "invalid project path"}) + return + } + const mergeRequestsSuffix = "/merge_requests" + if !strings.HasSuffix(decodedPath, mergeRequestsSuffix) { + if ctx.Request.Method == http.MethodGet { + ctx.JSON(http.StatusOK, gin.H{"default_branch": "main"}) + return + } + ctx.JSON(http.StatusNotFound, gin.H{"message": "mock GitLab endpoint not found"}) + return + } + projectPath := strings.TrimSuffix(decodedPath, mergeRequestsSuffix) + if strings.TrimSpace(projectPath) == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"message": "project path required"}) + return + } + + switch ctx.Request.Method { + case http.MethodGet: + c.listCreatedMRs(ctx, mock, projectPath) + case http.MethodPost: + c.createMR(ctx, mock, projectPath) + default: + ctx.Status(http.StatusMethodNotAllowed) + } +} + +func (c *MockController) listCreatedMRs(ctx *gin.Context, mock *MockClient, projectPath string) { + mrs, _ := mock.SearchMRs(ctx.Request.Context(), "", "") + sourceBranch := ctx.Query("source_branch") + targetBranch := ctx.Query("target_branch") + rows := make([]gin.H, 0) + for _, mr := range mrs { + if mr.ProjectPath != projectPath || (sourceBranch != "" && mr.HeadBranch != sourceBranch) || + (targetBranch != "" && mr.BaseBranch != targetBranch) { + continue + } + rows = append(rows, gin.H{ + "web_url": mr.WebURL, "source_branch": mr.HeadBranch, "target_branch": mr.BaseBranch, + }) + } + ctx.JSON(http.StatusOK, rows) +} + +func (c *MockController) createMR(ctx *gin.Context, mock *MockClient, projectPath string) { + var req mockCreateMRRequest + if err := ctx.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.SourceBranch) == "" || + strings.TrimSpace(req.TargetBranch) == "" || strings.TrimSpace(req.Title) == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"message": "source_branch, target_branch, and title required"}) + return + } + mr, err := mock.CreateMR(ctx.Request.Context(), projectPath, req.SourceBranch, req.TargetBranch, + req.Title, req.Description, strings.HasPrefix(strings.ToLower(strings.TrimSpace(req.Title)), "draft:")) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()}) + return + } + ctx.JSON(http.StatusCreated, gin.H{ + "id": mr.ID, "iid": mr.IID, "web_url": mr.WebURL, + "source_branch": mr.HeadBranch, "target_branch": mr.BaseBranch, + }) } type mockMRRequest struct { @@ -66,6 +171,45 @@ type mockBranchesRequest struct { Branches []RepoBranch `json:"branches"` } +type mockMembersRequest struct { + Project string `json:"project"` + Members []ProjectMember `json:"members"` +} + +type mockFilesRequest struct { + Project string `json:"project"` + IID int `json:"iid"` + Files []MRFile `json:"files"` +} + +type mockCommitsRequest struct { + Project string `json:"project"` + IID int `json:"iid"` + Commits []MRCommitInfo `json:"commits"` +} + +func (c *MockController) mockClient(ctx *gin.Context) (*MockClient, bool) { + workspaceID := strings.TrimSpace(ctx.Query("workspace_id")) + if workspaceID == "" { + return c.mock, true + } + client, err := c.service.ClientForWorkspace(ctx.Request.Context(), workspaceID) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, ErrNotConfigured) { + status = http.StatusServiceUnavailable + } + ctx.JSON(status, gin.H{"error": "mock GitLab workspace client unavailable"}) + return nil, false + } + mock, ok := client.(*MockClient) + if !ok { + ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "mock GitLab client unavailable"}) + return nil, false + } + return mock, true +} + func (c *MockController) setUser(ctx *gin.Context) { var req struct { Username string `json:"username"` @@ -74,7 +218,11 @@ func (c *MockController) setUser(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } - c.mock.SetUser(req.Username) + mock, ok := c.mockClient(ctx) + if !ok { + return + } + mock.SetUser(req.Username) ctx.JSON(http.StatusOK, gin.H{"username": req.Username}) } @@ -84,8 +232,12 @@ func (c *MockController) seedMRs(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "project and mrs required"}) return } + mock, ok := c.mockClient(ctx) + if !ok { + return + } for i := range req.MRs { - c.mock.SeedMR(req.Project, &req.MRs[i]) + mock.SeedMR(req.Project, &req.MRs[i]) } ctx.JSON(http.StatusOK, gin.H{"seeded": len(req.MRs)}) } @@ -96,8 +248,12 @@ func (c *MockController) seedIssues(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "project and issues required"}) return } + mock, ok := c.mockClient(ctx) + if !ok { + return + } for i := range req.Issues { - c.mock.SeedIssue(req.Project, &req.Issues[i]) + mock.SeedIssue(req.Project, &req.Issues[i]) } ctx.JSON(http.StatusOK, gin.H{"seeded": len(req.Issues)}) } @@ -108,7 +264,11 @@ func (c *MockController) seedPipelines(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "project and pipelines required"}) return } - c.mock.SeedPipelines(req.Project, req.Pipelines) + mock, ok := c.mockClient(ctx) + if !ok { + return + } + mock.SeedPipelines(req.Project, req.Pipelines) ctx.JSON(http.StatusOK, gin.H{"seeded": len(req.Pipelines)}) } @@ -118,7 +278,11 @@ func (c *MockController) seedDiscussions(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "project, iid, discussions required"}) return } - c.mock.SeedDiscussions(req.Project, req.IID, req.Discussions) + mock, ok := c.mockClient(ctx) + if !ok { + return + } + mock.SeedDiscussions(req.Project, req.IID, req.Discussions) ctx.JSON(http.StatusOK, gin.H{"seeded": len(req.Discussions)}) } @@ -128,7 +292,11 @@ func (c *MockController) seedApprovals(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "project and iid required"}) return } - c.mock.SeedApprovals(req.Project, req.IID, req.Approvals, req.Required) + mock, ok := c.mockClient(ctx) + if !ok { + return + } + mock.SeedApprovals(req.Project, req.IID, req.Approvals, req.Required) ctx.JSON(http.StatusOK, gin.H{"seeded": len(req.Approvals), "required": req.Required}) } @@ -138,6 +306,61 @@ func (c *MockController) seedBranches(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "project and branches required"}) return } - c.mock.SeedBranches(req.Project, req.Branches) + mock, ok := c.mockClient(ctx) + if !ok { + return + } + mock.SeedBranches(req.Project, req.Branches) ctx.JSON(http.StatusOK, gin.H{"seeded": len(req.Branches)}) } + +func (c *MockController) seedMembers(ctx *gin.Context) { + var req mockMembersRequest + if err := ctx.ShouldBindJSON(&req); err != nil || req.Project == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "project and members required"}) + return + } + mock, ok := c.mockClient(ctx) + if !ok { + return + } + mock.SeedProjectMembers(req.Project, req.Members) + ctx.JSON(http.StatusOK, gin.H{"seeded": len(req.Members)}) +} + +func (c *MockController) seedFiles(ctx *gin.Context) { + var req mockFilesRequest + if err := ctx.ShouldBindJSON(&req); err != nil || req.Project == "" || req.IID <= 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "project, iid, and files required"}) + return + } + mock, ok := c.mockClient(ctx) + if !ok { + return + } + mock.SeedFiles(req.Project, req.IID, req.Files) + ctx.JSON(http.StatusOK, gin.H{"seeded": len(req.Files)}) +} + +func (c *MockController) seedCommits(ctx *gin.Context) { + var req mockCommitsRequest + if err := ctx.ShouldBindJSON(&req); err != nil || req.Project == "" || req.IID <= 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "project, iid, and commits required"}) + return + } + mock, ok := c.mockClient(ctx) + if !ok { + return + } + mock.SeedCommits(req.Project, req.IID, req.Commits) + ctx.JSON(http.StatusOK, gin.H{"seeded": len(req.Commits)}) +} + +func (c *MockController) reset(ctx *gin.Context) { + mock, ok := c.mockClient(ctx) + if !ok { + return + } + mock.Reset() + ctx.JSON(http.StatusOK, gin.H{"reset": true}) +} diff --git a/apps/backend/internal/gitlab/mock_controller_test.go b/apps/backend/internal/gitlab/mock_controller_test.go new file mode 100644 index 0000000000..8d1ed928db --- /dev/null +++ b/apps/backend/internal/gitlab/mock_controller_test.go @@ -0,0 +1,145 @@ +package gitlab + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func newMockControllerFixture(t *testing.T) (*gin.Engine, *MockClient, *MockClient) { + t.Helper() + gin.SetMode(gin.TestMode) + legacy := NewMockClient(DefaultHost) + workspaceA := NewMockClient("https://gitlab-a.example.com") + workspaceB := NewMockClient("https://gitlab-b.example.com") + service := NewService(DefaultHost, legacy, "mock", nil, newTestLogger(t)) + service.SetStore(newTestStore(t)) + service.workspaceClients["workspace-a"] = workspaceA + service.workspaceClients["workspace-b"] = workspaceB + router := gin.New() + NewMockController(legacy, service, newTestLogger(t)).RegisterRoutes(router) + return router, workspaceA, workspaceB +} + +func mockControlRequest(t *testing.T, router *gin.Engine, method, target string, payload any) *httptest.ResponseRecorder { + t.Helper() + var body []byte + if payload != nil { + var err error + body, err = json.Marshal(payload) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + } + req := httptest.NewRequest(method, target, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + return res +} + +func TestMockControllerSeedsOnlyRequestedWorkspace(t *testing.T) { + router, workspaceA, workspaceB := newMockControllerFixture(t) + res := mockControlRequest(t, router, http.MethodPost, + "/api/v1/gitlab/mock/mrs?workspace_id=workspace-a", + mockMRRequest{Project: "group/project", MRs: []MR{{IID: 7, Title: "Workspace A"}}}) + if res.Code != http.StatusOK { + t.Fatalf("seed status=%d body=%s", res.Code, res.Body.String()) + } + if _, err := workspaceA.GetMR(t.Context(), "group/project", 7); err != nil { + t.Fatalf("workspace A MR missing: %v", err) + } + if _, err := workspaceB.GetMR(t.Context(), "group/project", 7); err == nil { + t.Fatal("workspace B observed workspace A seed") + } +} + +func TestMockControllerSeedsReviewDataAndResetsWorkspace(t *testing.T) { + router, workspaceA, workspaceB := newMockControllerFixture(t) + workspaceB.SeedMR("other/project", &MR{IID: 9, Title: "Keep"}) + + requests := []struct { + path string + body any + }{ + {"/members", mockMembersRequest{Project: "group/project", Members: []ProjectMember{{ID: 42, Username: "alice"}}}}, + {"/files", mockFilesRequest{Project: "group/project", IID: 7, Files: []MRFile{{Filename: "main.go"}}}}, + {"/commits", mockCommitsRequest{Project: "group/project", IID: 7, Commits: []MRCommitInfo{{SHA: "abc"}}}}, + } + workspaceA.SeedMR("group/project", &MR{IID: 7, Title: "Reset me"}) + for _, item := range requests { + res := mockControlRequest(t, router, http.MethodPost, + "/api/v1/gitlab/mock"+item.path+"?workspace_id=workspace-a", item.body) + if res.Code != http.StatusOK { + t.Fatalf("seed %s status=%d body=%s", item.path, res.Code, res.Body.String()) + } + } + + res := mockControlRequest(t, router, http.MethodDelete, + "/api/v1/gitlab/mock/reset?workspace_id=workspace-a", nil) + if res.Code != http.StatusOK { + t.Fatalf("reset status=%d body=%s", res.Code, res.Body.String()) + } + if got := workspaceA.Stats(); got != "mrs=0 discussions=0 issues=0" { + t.Fatalf("workspace A after reset = %q", got) + } + if _, err := workspaceB.GetMR(t.Context(), "other/project", 9); err != nil { + t.Fatalf("workspace B was reset: %v", err) + } +} + +func TestMockControllerRejectsUnknownWorkspace(t *testing.T) { + router, _, _ := newMockControllerFixture(t) + res := mockControlRequest(t, router, http.MethodPost, + "/api/v1/gitlab/mock/mrs?workspace_id=missing", + mockMRRequest{Project: "group/project", MRs: []MR{{IID: 7}}}) + if res.Code != http.StatusServiceUnavailable { + t.Fatalf("status=%d body=%s", res.Code, res.Body.String()) + } +} + +func TestMockControllerServesWorkspaceScopedGitLabCreationAPI(t *testing.T) { + router, workspaceA, workspaceB := newMockControllerFixture(t) + payload := mockCreateMRRequest{ + SourceBranch: "feature/gitlab", TargetBranch: "main", Title: "Draft: Created from task", + Description: "Creation body", + } + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + req := httptest.NewRequest(http.MethodPost, + "/api/v4/projects/group%2Fproject/merge_requests", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("PRIVATE-TOKEN", mockWorkspaceTokenPrefix+"workspace-a") + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + if res.Code != http.StatusCreated { + t.Fatalf("create status=%d body=%s", res.Code, res.Body.String()) + } + mr, err := workspaceA.GetMR(t.Context(), "group/project", 100) + if err != nil { + t.Fatalf("workspace A created MR missing: %v", err) + } + if mr.Title != payload.Title || !mr.Draft || mr.HeadBranch != payload.SourceBranch { + t.Fatalf("created MR = %#v", mr) + } + if _, err := workspaceB.GetMR(t.Context(), "group/project", 100); err == nil { + t.Fatal("workspace B observed workspace A REST-created MR") + } + + get := httptest.NewRequest(http.MethodGet, + "/api/v4/projects/group%2Fproject/merge_requests?source_branch=feature%2Fgitlab&target_branch=main", + nil) + get.Header.Set("PRIVATE-TOKEN", mockWorkspaceTokenPrefix+"workspace-a") + listed := httptest.NewRecorder() + router.ServeHTTP(listed, get) + if listed.Code != http.StatusOK || !strings.Contains(listed.Body.String(), mr.WebURL) { + t.Fatalf("list status=%d body=%s", listed.Code, listed.Body.String()) + } +} diff --git a/apps/backend/internal/gitlab/models.go b/apps/backend/internal/gitlab/models.go index 8f2dc9dd0a..f152f9b0d2 100644 --- a/apps/backend/internal/gitlab/models.go +++ b/apps/backend/internal/gitlab/models.go @@ -6,6 +6,37 @@ package gitlab import "time" +// GitLabConfig is the workspace-owned GitLab connection metadata. Credentials +// are stored separately under SecretKeyForWorkspace and never serialized. +type GitLabConfig struct { + WorkspaceID string `json:"workspace_id" db:"workspace_id"` + Host string `json:"host" db:"host"` + AuthMethod string `json:"auth_method" db:"auth_method"` + Username string `json:"username" db:"username"` + HasSecret bool `json:"has_secret" db:"-"` + LastOK bool `json:"last_ok" db:"last_ok"` + LastError string `json:"last_error,omitempty" db:"last_error"` + LastCheckedAt *time.Time `json:"last_checked_at,omitempty" db:"last_checked_at"` + Revision int64 `json:"-" db:"revision"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// SetConfigRequest creates or updates a workspace GitLab connection. An empty +// token retains the existing PAT when AuthMethod is pat. +type SetConfigRequest struct { + Host string `json:"host"` + AuthMethod string `json:"auth_method"` + Token string `json:"token,omitempty"` +} + +// TestConnectionResult is returned by the non-persisting connection probe. +type TestConnectionResult struct { + OK bool `json:"ok"` + Username string `json:"username,omitempty"` + Error string `json:"error,omitempty"` +} + // MR represents a GitLab Merge Request. // // IID is GitLab's per-project sequential ID (the number shown in the UI). @@ -33,6 +64,7 @@ type MR struct { Deletions int `json:"deletions"` Reviewers []MRReviewer `json:"reviewers"` Assignees []MRReviewer `json:"assignees"` + Labels []string `json:"labels"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` MergedAt *time.Time `json:"merged_at,omitempty"` @@ -42,11 +74,27 @@ type MR struct { // MRReviewer represents a reviewer or assignee on an MR. // GitLab does not have team-level review requests, so Type is always "user". type MRReviewer struct { + ID int64 `json:"id"` Username string `json:"username"` Name string `json:"name"` Type string `json:"type"` } +// ProjectMember is an active GitLab user eligible for project-level actions +// such as merge-request reviewer assignment. +type ProjectMember struct { + ID int64 `json:"id"` + Username string `json:"username"` + Name string `json:"name"` + AvatarURL string `json:"avatar_url"` +} + +// SubscriptionState is the live notification subscription state owned by +// GitLab for an issue or merge request. +type SubscriptionState struct { + Subscribed bool `json:"subscribed"` +} + // MRApproval represents a single approval on a merge request. // GitLab approvals are simpler than GitHub reviews — there is no // COMMENTED / DISMISSED state, only approved/not-approved. diff --git a/apps/backend/internal/gitlab/noop_client.go b/apps/backend/internal/gitlab/noop_client.go index d8dde70e6e..b0ceca620c 100644 --- a/apps/backend/internal/gitlab/noop_client.go +++ b/apps/backend/internal/gitlab/noop_client.go @@ -158,3 +158,27 @@ func (c *NoopClient) SetMRLabels(context.Context, string, int, []string) error { func (c *NoopClient) SetMRAssignees(context.Context, string, int, []int) error { return ErrNoClient } + +func (c *NoopClient) ListProjectMembers(context.Context, string, string) ([]ProjectMember, error) { + return nil, ErrNoClient +} + +func (c *NoopClient) SetMRReviewers(context.Context, string, int, []int64) error { + return ErrNoClient +} + +func (c *NoopClient) GetMRSubscription(context.Context, string, int) (*SubscriptionState, error) { + return nil, ErrNoClient +} + +func (c *NoopClient) SetMRSubscription(context.Context, string, int, bool) (*SubscriptionState, error) { + return nil, ErrNoClient +} + +func (c *NoopClient) GetIssueSubscription(context.Context, string, int) (*SubscriptionState, error) { + return nil, ErrNoClient +} + +func (c *NoopClient) SetIssueSubscription(context.Context, string, int, bool) (*SubscriptionState, error) { + return nil, ErrNoClient +} diff --git a/apps/backend/internal/gitlab/noop_client_test.go b/apps/backend/internal/gitlab/noop_client_test.go index b6f13bbea5..d2812c9b5d 100644 --- a/apps/backend/internal/gitlab/noop_client_test.go +++ b/apps/backend/internal/gitlab/noop_client_test.go @@ -57,6 +57,12 @@ func TestNoopClient_DataMethods_ReturnErrNoClient(t *testing.T) { {"SubmitMRApproval", c.SubmitMRApproval(ctx, "g/p", 1)}, {"SubmitMRUnapproval", c.SubmitMRUnapproval(ctx, "g/p", 1)}, {"CreateMR", mustErr2(c.CreateMR(ctx, "g/p", "src", "dst", "t", "b", false))}, + {"ListProjectMembers", mustErr2(c.ListProjectMembers(ctx, "g/p", "alice"))}, + {"SetMRReviewers", c.SetMRReviewers(ctx, "g/p", 1, []int64{42})}, + {"GetMRSubscription", mustErr2(c.GetMRSubscription(ctx, "g/p", 1))}, + {"SetMRSubscription", mustErr2(c.SetMRSubscription(ctx, "g/p", 1, true))}, + {"GetIssueSubscription", mustErr2(c.GetIssueSubscription(ctx, "g/p", 1))}, + {"SetIssueSubscription", mustErr2(c.SetIssueSubscription(ctx, "g/p", 1, true))}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/apps/backend/internal/gitlab/pat_client.go b/apps/backend/internal/gitlab/pat_client.go index 91dd8f0e18..47d925a8a1 100644 --- a/apps/backend/internal/gitlab/pat_client.go +++ b/apps/backend/internal/gitlab/pat_client.go @@ -32,7 +32,7 @@ type APIError struct { } func (e *APIError) Error() string { - return fmt.Sprintf("GitLab API %s returned %d: %s", e.Endpoint, e.StatusCode, e.Body) + return fmt.Sprintf("GitLab API %s returned status %d", e.Endpoint, e.StatusCode) } // PATClient implements Client against the GitLab REST v4 API using a @@ -626,6 +626,11 @@ func (c *PATClient) postJSON(ctx context.Context, endpoint string, payload, resu } func (c *PATClient) doWrite(ctx context.Context, method, endpoint string, body []byte, result any) error { + _, err := c.doWriteWithStatus(ctx, method, endpoint, body, result) + return err +} + +func (c *PATClient) doWriteWithStatus(ctx context.Context, method, endpoint string, body []byte, result any) (int, error) { u := c.host + apiPathPrefix + endpoint var bodyReader io.Reader if body != nil { @@ -633,7 +638,7 @@ func (c *PATClient) doWrite(ctx context.Context, method, endpoint string, body [ } req, err := http.NewRequestWithContext(ctx, method, u, bodyReader) if err != nil { - return err + return 0, err } c.setHeaders(req) if body != nil { @@ -642,20 +647,20 @@ func (c *PATClient) doWrite(ctx context.Context, method, endpoint string, body [ resp, err := c.httpClient.Do(req) if err != nil { - return fmt.Errorf("request %s %s: %w", method, endpoint, err) + return 0, fmt.Errorf("request %s %s: %w", method, endpoint, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= 400 { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, defaultErrBytes)) - return &APIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: string(respBody)} + return resp.StatusCode, &APIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: string(respBody)} } if result == nil { // Drain to allow HTTP/1.1 connection reuse on the next call. _, _ = io.Copy(io.Discard, resp.Body) - return nil + return resp.StatusCode, nil } - return json.NewDecoder(resp.Body).Decode(result) + return resp.StatusCode, json.NewDecoder(resp.Body).Decode(result) } // parseNextLink extracts the "next" URL path+query from a GitLab Link header diff --git a/apps/backend/internal/gitlab/pat_client_actions.go b/apps/backend/internal/gitlab/pat_client_actions.go index 966198a45c..061c1de728 100644 --- a/apps/backend/internal/gitlab/pat_client_actions.go +++ b/apps/backend/internal/gitlab/pat_client_actions.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "net/http" "net/url" "strings" ) @@ -179,3 +180,90 @@ func (c *PATClient) SetMRAssignees(ctx context.Context, projectPath string, iid } return nil } + +func (c *PATClient) ListProjectMembers(ctx context.Context, projectPath, query string) ([]ProjectMember, error) { + endpoint := fmt.Sprintf("/projects/%s/members/all?per_page=100", projectRef(projectPath)) + if strings.TrimSpace(query) != "" { + endpoint += "&query=" + url.QueryEscape(query) + } + var raw []struct { + ID int64 `json:"id"` + Username string `json:"username"` + Name string `json:"name"` + AvatarURL string `json:"avatar_url"` + State string `json:"state"` + } + if err := c.get(ctx, endpoint, &raw); err != nil { + return nil, fmt.Errorf("list project members: %w", err) + } + members := make([]ProjectMember, 0, len(raw)) + for _, member := range raw { + if member.State != "" && member.State != "active" { + continue + } + members = append(members, ProjectMember{ + ID: member.ID, + Username: member.Username, + Name: member.Name, + AvatarURL: member.AvatarURL, + }) + } + return members, nil +} + +func (c *PATClient) SetMRReviewers(ctx context.Context, projectPath string, iid int, reviewerIDs []int64) error { + if reviewerIDs == nil { + reviewerIDs = []int64{} + } + body, err := json.Marshal(map[string][]int64{"reviewer_ids": reviewerIDs}) + if err != nil { + return fmt.Errorf("encode set-reviewers payload: %w", err) + } + endpoint := fmt.Sprintf("/projects/%s/merge_requests/%d", projectRef(projectPath), iid) + if err := c.doWrite(ctx, http.MethodPut, endpoint, body, nil); err != nil { + return fmt.Errorf("set MR reviewers: %w", err) + } + return nil +} + +func (c *PATClient) GetMRSubscription(ctx context.Context, projectPath string, iid int) (*SubscriptionState, error) { + return c.getSubscription(ctx, projectPath, "merge_requests", iid) +} + +func (c *PATClient) SetMRSubscription(ctx context.Context, projectPath string, iid int, subscribed bool) (*SubscriptionState, error) { + return c.setSubscription(ctx, projectPath, "merge_requests", iid, subscribed) +} + +func (c *PATClient) GetIssueSubscription(ctx context.Context, projectPath string, iid int) (*SubscriptionState, error) { + return c.getSubscription(ctx, projectPath, "issues", iid) +} + +func (c *PATClient) SetIssueSubscription(ctx context.Context, projectPath string, iid int, subscribed bool) (*SubscriptionState, error) { + return c.setSubscription(ctx, projectPath, "issues", iid, subscribed) +} + +func (c *PATClient) getSubscription(ctx context.Context, projectPath, resource string, iid int) (*SubscriptionState, error) { + endpoint := fmt.Sprintf("/projects/%s/%s/%d", projectRef(projectPath), resource, iid) + var state SubscriptionState + if err := c.get(ctx, endpoint, &state); err != nil { + return nil, fmt.Errorf("get %s subscription: %w", resource, err) + } + return &state, nil +} + +func (c *PATClient) setSubscription(ctx context.Context, projectPath, resource string, iid int, subscribed bool) (*SubscriptionState, error) { + action := "unsubscribe" + if subscribed { + action = "subscribe" + } + endpoint := fmt.Sprintf("/projects/%s/%s/%d/%s", projectRef(projectPath), resource, iid, action) + var state SubscriptionState + status, err := c.doWriteWithStatus(ctx, http.MethodPost, endpoint, nil, &state) + if status == http.StatusNotModified { + return &SubscriptionState{Subscribed: subscribed}, nil + } + if err != nil { + return nil, fmt.Errorf("%s to %s notifications: %w", action, resource, err) + } + return &state, nil +} diff --git a/apps/backend/internal/gitlab/pat_client_member_subscriptions_test.go b/apps/backend/internal/gitlab/pat_client_member_subscriptions_test.go new file mode 100644 index 0000000000..01070f3a0d --- /dev/null +++ b/apps/backend/internal/gitlab/pat_client_member_subscriptions_test.go @@ -0,0 +1,193 @@ +package gitlab + +import ( + "context" + "encoding/json" + "net/http" + "reflect" + "testing" +) + +func TestPATClientListProjectMembersReturnsActiveNumericIDs(t *testing.T) { + host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got, want := r.URL.EscapedPath(), "/projects/group%2Fsub%2Fproject/members/all"; got != want { + t.Errorf("escaped path = %q, want %q", got, want) + } + if got, want := r.URL.Query().Get("query"), "ali ce"; got != want { + t.Errorf("query = %q, want %q", got, want) + } + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"id": 42, "username": "alice", "name": "Alice", "avatar_url": "https://img/alice", "state": "active"}, + {"id": 7, "username": "away", "name": "Away", "state": "blocked"}, + }) + })) + defer stop() + + members, err := NewPATClient(host, "tok").ListProjectMembers(t.Context(), "group/sub/project", "ali ce") + if err != nil { + t.Fatalf("list project members: %v", err) + } + want := []ProjectMember{{ID: 42, Username: "alice", Name: "Alice", AvatarURL: "https://img/alice"}} + if !reflect.DeepEqual(members, want) { + t.Fatalf("members = %#v, want %#v", members, want) + } +} + +func TestPATClientSetMRReviewersSendsReplacementIDs(t *testing.T) { + for _, tc := range []struct { + name string + ids []int64 + }{ + {name: "replace", ids: []int64{42, 91}}, + {name: "clear", ids: []int64{}}, + } { + t.Run(tc.name, func(t *testing.T) { + var payload struct { + ReviewerIDs []int64 `json:"reviewer_ids"` + } + host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got, want := r.Method, http.MethodPut; got != want { + t.Errorf("method = %s, want %s", got, want) + } + if got, want := r.URL.EscapedPath(), "/projects/group%2Fproject/merge_requests/12"; got != want { + t.Errorf("escaped path = %q, want %q", got, want) + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode reviewer payload: %v", err) + } + })) + defer stop() + + if err := NewPATClient(host, "tok").SetMRReviewers(t.Context(), "group/project", 12, tc.ids); err != nil { + t.Fatalf("set MR reviewers: %v", err) + } + if !reflect.DeepEqual(payload.ReviewerIDs, tc.ids) { + t.Fatalf("reviewer_ids = %#v, want %#v", payload.ReviewerIDs, tc.ids) + } + }) + } +} + +func TestPATClientSubscriptionReadsCorrectResource(t *testing.T) { + for _, tc := range []struct { + name string + path string + get func(*PATClient) (*SubscriptionState, error) + }{ + { + name: "merge request", + path: "/projects/group%2Fproject/merge_requests/12", + get: func(c *PATClient) (*SubscriptionState, error) { + return c.GetMRSubscription(context.Background(), "group/project", 12) + }, + }, + { + name: "issue", + path: "/projects/group%2Fproject/issues/12", + get: func(c *PATClient) (*SubscriptionState, error) { + return c.GetIssueSubscription(context.Background(), "group/project", 12) + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.EscapedPath(); got != tc.path { + t.Errorf("escaped path = %q, want %q", got, tc.path) + } + _, _ = w.Write([]byte(`{"subscribed":true}`)) + })) + defer stop() + + state, err := tc.get(NewPATClient(host, "tok")) + if err != nil { + t.Fatalf("get subscription: %v", err) + } + if !state.Subscribed { + t.Fatal("subscribed = false, want true") + } + }) + } +} + +func TestPATClientSubscriptionWritesCorrectResource(t *testing.T) { + for _, tc := range []struct { + name string + path string + subscribed bool + set func(*PATClient, bool) (*SubscriptionState, error) + }{ + {"subscribe MR", "/projects/group%2Fproject/merge_requests/12/subscribe", true, func(c *PATClient, value bool) (*SubscriptionState, error) { + return c.SetMRSubscription(context.Background(), "group/project", 12, value) + }}, + {"unsubscribe MR", "/projects/group%2Fproject/merge_requests/12/unsubscribe", false, func(c *PATClient, value bool) (*SubscriptionState, error) { + return c.SetMRSubscription(context.Background(), "group/project", 12, value) + }}, + {"subscribe issue", "/projects/group%2Fproject/issues/12/subscribe", true, func(c *PATClient, value bool) (*SubscriptionState, error) { + return c.SetIssueSubscription(context.Background(), "group/project", 12, value) + }}, + {"unsubscribe issue", "/projects/group%2Fproject/issues/12/unsubscribe", false, func(c *PATClient, value bool) (*SubscriptionState, error) { + return c.SetIssueSubscription(context.Background(), "group/project", 12, value) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got, want := r.Method, http.MethodPost; got != want { + t.Errorf("method = %s, want %s", got, want) + } + if got := r.URL.EscapedPath(); got != tc.path { + t.Errorf("escaped path = %q, want %q", got, tc.path) + } + _, _ = w.Write([]byte(`{"subscribed":` + map[bool]string{true: "true", false: "false"}[tc.subscribed] + `}`)) + })) + defer stop() + + state, err := tc.set(NewPATClient(host, "tok"), tc.subscribed) + if err != nil { + t.Fatalf("set subscription: %v", err) + } + if state.Subscribed != tc.subscribed { + t.Fatalf("subscribed = %t, want %t", state.Subscribed, tc.subscribed) + } + }) + } +} + +func TestPATClientSubscriptionWriteTreatsNotModifiedAsIdempotentSuccess(t *testing.T) { + for _, tc := range []struct { + name string + path string + subscribed bool + set func(*PATClient, bool) (*SubscriptionState, error) + }{ + {"subscribe MR", "/projects/group%2Fproject/merge_requests/12/subscribe", true, func(c *PATClient, value bool) (*SubscriptionState, error) { + return c.SetMRSubscription(context.Background(), "group/project", 12, value) + }}, + {"unsubscribe MR", "/projects/group%2Fproject/merge_requests/12/unsubscribe", false, func(c *PATClient, value bool) (*SubscriptionState, error) { + return c.SetMRSubscription(context.Background(), "group/project", 12, value) + }}, + {"subscribe issue", "/projects/group%2Fproject/issues/12/subscribe", true, func(c *PATClient, value bool) (*SubscriptionState, error) { + return c.SetIssueSubscription(context.Background(), "group/project", 12, value) + }}, + {"unsubscribe issue", "/projects/group%2Fproject/issues/12/unsubscribe", false, func(c *PATClient, value bool) (*SubscriptionState, error) { + return c.SetIssueSubscription(context.Background(), "group/project", 12, value) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.EscapedPath(); got != tc.path { + t.Errorf("escaped path = %q, want %q", got, tc.path) + } + w.WriteHeader(http.StatusNotModified) + })) + defer stop() + + state, err := tc.set(NewPATClient(host, "tok"), tc.subscribed) + if err != nil { + t.Fatalf("set subscription returned error for idempotent 304: %v", err) + } + if state.Subscribed != tc.subscribed { + t.Fatalf("subscribed = %t, want requested state %t", state.Subscribed, tc.subscribed) + } + }) + } +} diff --git a/apps/backend/internal/gitlab/pat_client_test.go b/apps/backend/internal/gitlab/pat_client_test.go index 5a145f82ca..4eb8e45f69 100644 --- a/apps/backend/internal/gitlab/pat_client_test.go +++ b/apps/backend/internal/gitlab/pat_client_test.go @@ -13,6 +13,10 @@ import ( "time" ) +func TestPATClientProviderActionContract(t *testing.T) { + var _ Client = (*PATClient)(nil) +} + // newTestServer wires a handler under /api/v4/* and returns the host URL. func newTestServer(t *testing.T, handler http.Handler) (host string, teardown func()) { t.Helper() @@ -115,6 +119,43 @@ func TestPATClient_GetMR(t *testing.T) { } } +func TestPATClientSetMRLabelsThenFeedbackHydratesReplacement(t *testing.T) { + labels := []string{"old"} + host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut { + var body struct { + Labels string `json:"labels"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + labels = strings.Split(body.Labels, ",") + w.WriteHeader(http.StatusOK) + return + } + if strings.HasSuffix(r.URL.Path, "/approvals") { + _, _ = w.Write([]byte(`{}`)) + return + } + if strings.HasSuffix(r.URL.Path, "/discussions") || strings.Contains(r.URL.Path, "/pipelines") { + _, _ = w.Write([]byte(`[]`)) + return + } + _ = json.NewEncoder(w).Encode(rawMR{IID: 7, Labels: labels}) + })) + defer stop() + + client := NewPATClient(host, "tok") + if err := client.SetMRLabels(t.Context(), "team/repo", 7, []string{"bug", "backend"}); err != nil { + t.Fatalf("set labels: %v", err) + } + feedback, err := client.GetMRFeedback(t.Context(), "team/repo", 7) + if err != nil { + t.Fatalf("feedback: %v", err) + } + if got := feedback.MR.Labels; len(got) != 2 || got[0] != "bug" || got[1] != "backend" { + t.Fatalf("labels = %#v, want replacement", got) + } +} + func TestPATClient_FindMRByBranch_Empty(t *testing.T) { host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`[]`)) diff --git a/apps/backend/internal/gitlab/poller.go b/apps/backend/internal/gitlab/poller.go index 9eb7b9dcc2..16c97e7132 100644 --- a/apps/backend/internal/gitlab/poller.go +++ b/apps/backend/internal/gitlab/poller.go @@ -9,6 +9,7 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/integrations/healthpoll" ) const ( @@ -22,6 +23,7 @@ type Poller struct { service *Service eventBus bus.EventBus logger *logger.Logger + auth *healthpoll.Poller mu sync.Mutex cancel context.CancelFunc @@ -31,12 +33,21 @@ type Poller struct { // NewPoller creates a new background poller. func NewPoller(svc *Service, eventBus bus.EventBus, log *logger.Logger) *Poller { - return &Poller{service: svc, eventBus: eventBus, logger: log} + if svc == nil { + return nil + } + return &Poller{ + service: svc, eventBus: eventBus, logger: log, + auth: healthpoll.New("gitlab", gitLabHealthProber{service: svc}, log), + } } // Start kicks off the polling loops. Repeated Start calls are no-ops. // Safe to call concurrently with Stop. func (p *Poller) Start(ctx context.Context) { + if p == nil { + return + } p.mu.Lock() defer p.mu.Unlock() if p.started { @@ -44,6 +55,7 @@ func (p *Poller) Start(ctx context.Context) { } p.started = true ctx, p.cancel = context.WithCancel(ctx) + p.auth.Start(ctx) p.wg.Add(3) go p.mrMonitorLoop(ctx) @@ -54,6 +66,9 @@ func (p *Poller) Start(ctx context.Context) { // Stop cancels all loops and waits for them to drain. Safe to call // concurrently with Start. func (p *Poller) Stop() { + if p == nil { + return + } p.mu.Lock() if !p.started || p.cancel == nil { p.mu.Unlock() @@ -62,12 +77,32 @@ func (p *Poller) Stop() { cancel := p.cancel p.mu.Unlock() cancel() + p.auth.Stop() p.wg.Wait() p.mu.Lock() p.started = false p.mu.Unlock() } +type gitLabHealthProber struct { + service *Service +} + +func (p gitLabHealthProber) HasConfig(ctx context.Context) (bool, error) { + p.service.mu.RLock() + store := p.service.store + p.service.mu.RUnlock() + if store == nil { + return false, nil + } + ids, err := store.ListConfigWorkspaceIDs(ctx) + return len(ids) > 0, err +} + +func (p gitLabHealthProber) RecordAuthHealth(ctx context.Context) { + p.service.RecordWorkspaceAuthHealth(ctx) +} + // --- MR watcher loop --- func (p *Poller) mrMonitorLoop(ctx context.Context) { diff --git a/apps/backend/internal/gitlab/poller_health_test.go b/apps/backend/internal/gitlab/poller_health_test.go new file mode 100644 index 0000000000..4d68f015e3 --- /dev/null +++ b/apps/backend/internal/gitlab/poller_health_test.go @@ -0,0 +1,50 @@ +package gitlab + +import ( + "context" + "testing" + "time" +) + +func TestPollerStartRecordsHealthForEveryConfiguredWorkspace(t *testing.T) { + t.Setenv("KANDEV_MOCK_GITLAB", "true") + t.Setenv(secretNameToken, "test-token") + store := newTestStore(t) + for _, workspaceID := range []string{"workspace-a", "workspace-b"} { + seedWorkspace(t, store, workspaceID) + if err := store.UpsertConfigForWorkspace(context.Background(), workspaceID, &GitLabConfig{ + Host: DefaultHost, AuthMethod: AuthMethodEnvironment, + }); err != nil { + t.Fatalf("seed config %s: %v", workspaceID, err) + } + } + svc := newWorkspaceConfigService(t, store, &configTestSecrets{values: make(map[string]string)}) + poller := NewPoller(svc, nil, newTestLogger(t)) + ctx, cancel := context.WithCancel(context.Background()) + poller.Start(ctx) + t.Cleanup(func() { cancel(); poller.Stop() }) + deadline := time.Now().Add(time.Second) + for { + cfg, err := store.GetConfigForWorkspace(context.Background(), "workspace-a") + if err != nil { + t.Fatalf("get config workspace-a: %v", err) + } + if cfg.LastCheckedAt != nil { + break + } + if time.Now().After(deadline) { + t.Fatal("timed out waiting for immediate health probe") + } + time.Sleep(time.Millisecond) + } + + for _, workspaceID := range []string{"workspace-a", "workspace-b"} { + cfg, err := store.GetConfigForWorkspace(context.Background(), workspaceID) + if err != nil { + t.Fatalf("get config %s: %v", workspaceID, err) + } + if cfg.LastCheckedAt == nil || !cfg.LastOK || cfg.Username != "kandev-tester" { + t.Fatalf("health for %s = %#v", workspaceID, cfg) + } + } +} diff --git a/apps/backend/internal/gitlab/provider.go b/apps/backend/internal/gitlab/provider.go index ef1cbc9327..d7a2effe59 100644 --- a/apps/backend/internal/gitlab/provider.go +++ b/apps/backend/internal/gitlab/provider.go @@ -2,9 +2,14 @@ package gitlab import ( "context" + "errors" "fmt" + "os" + "strings" + "time" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/integrations/workspacescope" ) // Provide builds the GitLab service stack: discovers a host (via the @@ -44,3 +49,133 @@ func Provide( cleanup := func() error { return nil } return svc, cleanup, nil } + +// MigrateLegacyConnection moves the installation-wide host/token onto the +// deterministic active-or-earliest workspace. Existing workspace config wins. +func MigrateLegacyConnection(ctx context.Context, store *Store, workspaceSecrets WorkspaceSecretStore, legacySecrets SecretProvider, legacyManager SecretManager, hostStore HostStore, log *logger.Logger) error { + if store == nil { + return nil + } + target, err := workspacescope.ResolveMigrationTarget(store.db) + if err != nil { + return err + } + existing, err := store.GetConfigForWorkspace(ctx, target) + if err != nil || existing != nil { + return err + } + legacy, err := loadLegacyConnection(ctx, hostStore, legacySecrets, workspaceSecrets) + if err != nil { + return err + } + if legacy == nil { + return nil + } + var previousSecret workspaceSecretSnapshot + if legacy.token != "" { + previousSecret, err = snapshotWorkspaceSecret(ctx, workspaceSecrets, SecretKeyForWorkspace(target)) + if err != nil { + return fmt.Errorf("snapshot migrated GitLab token: %w", err) + } + if err := workspaceSecrets.Set(ctx, SecretKeyForWorkspace(target), "GitLab token", legacy.token); err != nil { + return restoreLegacyWorkspaceSecret(ctx, workspaceSecrets, target, previousSecret, err) + } + } + cfg := &GitLabConfig{Host: legacy.host, AuthMethod: legacy.authMethod, CreatedAt: time.Now().UTC()} + if err := store.UpsertConfigForWorkspace(ctx, target, cfg); err != nil { + if legacy.token != "" { + return restoreLegacyWorkspaceSecret(ctx, workspaceSecrets, target, previousSecret, err) + } + return err + } + cleanupLegacyConnection(ctx, legacy.tokenID, legacyManager, hostStore, log) + return nil +} + +func restoreLegacyWorkspaceSecret(ctx context.Context, secrets WorkspaceSecretStore, workspaceID string, snapshot workspaceSecretSnapshot, cause error) error { + rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + if err := restoreWorkspaceSecret(rollbackCtx, secrets, SecretKeyForWorkspace(workspaceID), snapshot); err != nil { + return errors.Join(cause, fmt.Errorf("restore migrated GitLab token: %w", err)) + } + return cause +} + +type legacyConnection struct { + host string + authMethod string + token string + tokenID string +} + +func loadLegacyConnection(ctx context.Context, hostStore HostStore, legacySecrets SecretProvider, workspaceSecrets WorkspaceSecretStore) (*legacyConnection, error) { + host, err := loadLegacyHost(ctx, hostStore) + if err != nil { + return nil, err + } + token, tokenID, err := findLegacyPAT(ctx, legacySecrets) + if err != nil { + return nil, err + } + environmentToken := strings.TrimSpace(os.Getenv(secretNameToken)) + if host == "" && token == "" && environmentToken == "" { + return nil, nil + } + host, err = normalizeHostOrigin(host) + if err != nil { + return nil, err + } + authMethod, err := legacyAuthMethod(token, environmentToken, workspaceSecrets) + if err != nil { + return nil, err + } + return &legacyConnection{host: host, authMethod: authMethod, token: token, tokenID: tokenID}, nil +} + +func loadLegacyHost(ctx context.Context, hostStore HostStore) (string, error) { + if hostStore == nil { + return "", nil + } + return hostStore.GetHost(ctx) +} + +func legacyAuthMethod(token, environmentToken string, workspaceSecrets WorkspaceSecretStore) (string, error) { + if token != "" { + if workspaceSecrets == nil { + return "", errors.New("gitlab workspace secret store is required to migrate a PAT") + } + return AuthMethodPAT, nil + } + if environmentToken != "" { + return AuthMethodEnvironment, nil + } + return AuthMethodGLab, nil +} + +func cleanupLegacyConnection(ctx context.Context, tokenID string, legacyManager SecretManager, hostStore HostStore, log *logger.Logger) { + if tokenID != "" && legacyManager != nil { + if err := legacyManager.Delete(ctx, tokenID); err != nil && log != nil { + log.Warn("GitLab legacy token cleanup failed") + } + } + if hostStore != nil { + _ = hostStore.SetHost(ctx, "") + } +} + +func findLegacyPAT(ctx context.Context, secrets SecretProvider) (string, string, error) { + if secrets == nil { + return "", "", nil + } + items, err := secrets.List(ctx) + if err != nil { + return "", "", err + } + for _, item := range items { + if item.HasValue && (item.Name == secretNameToken || item.Name == secretNameTokenLower) { + value, err := secrets.Reveal(ctx, item.ID) + return value, item.ID, err + } + } + return "", "", nil +} diff --git a/apps/backend/internal/gitlab/provider_test.go b/apps/backend/internal/gitlab/provider_test.go index d5a19f6a32..2e2164724a 100644 --- a/apps/backend/internal/gitlab/provider_test.go +++ b/apps/backend/internal/gitlab/provider_test.go @@ -13,10 +13,77 @@ func (failingHostStore) GetHost(context.Context) (string, error) { return "", errors.New("settings unavailable") } +type staticHostStore struct{ host string } + +func (s *staticHostStore) GetHost(context.Context) (string, error) { return s.host, nil } +func (s *staticHostStore) SetHost(_ context.Context, host string) error { + s.host = host + return nil +} + func (failingHostStore) SetHost(context.Context, string) error { return nil } +func TestMigrateLegacyConnectionKeepsPATWhenWorkspaceSecretStoreIsUnavailable(t *testing.T) { + store := newTestStore(t) + seedWorkspace(t, store, "workspace-a") + legacy := newFakeSecretManager() + legacy.items = []*SecretListItem{{ID: "legacy-token", Name: secretNameToken, HasValue: true}} + legacy.values["legacy-token"] = "glpat-legacy" + hosts := &staticHostStore{host: "http://gitlab.internal"} + + err := MigrateLegacyConnection( + context.Background(), store, nil, legacy, legacy, hosts, newTestLogger(t), + ) + if err == nil { + t.Fatal("expected migration to require workspace secret storage") + } + cfg, getErr := store.GetConfigForWorkspace(context.Background(), "workspace-a") + if getErr != nil { + t.Fatalf("get config: %v", getErr) + } + if cfg != nil { + t.Fatalf("config was persisted without its PAT: %#v", cfg) + } + if legacy.deleteCalls != 0 || legacy.values["legacy-token"] != "glpat-legacy" { + t.Fatal("legacy PAT was deleted when migration could not persist the workspace secret") + } +} + +func TestMigrateLegacyConnectionRestoresWorkspaceSecretWhenConfigWriteFails(t *testing.T) { + store := newTestStore(t) + seedWorkspace(t, store, "workspace-a") + if _, err := store.db.Exec(`CREATE TRIGGER fail_gitlab_migration_config + BEFORE INSERT ON gitlab_configs + BEGIN SELECT RAISE(FAIL, 'injected migration config failure'); END`); err != nil { + t.Fatalf("install migration failure trigger: %v", err) + } + legacy := newFakeSecretManager() + legacy.items = []*SecretListItem{{ID: "legacy-token", Name: secretNameToken, HasValue: true}} + legacy.values["legacy-token"] = "glpat-legacy" + workspaceSecrets := &configTestSecrets{values: map[string]string{ + SecretKeyForWorkspace("workspace-a"): "previous-workspace-token", + }} + hosts := &staticHostStore{host: "http://gitlab.internal"} + + err := MigrateLegacyConnection( + context.Background(), store, workspaceSecrets, legacy, legacy, hosts, newTestLogger(t), + ) + if err == nil { + t.Fatal("expected migration config failure") + } + if got := workspaceSecrets.values[SecretKeyForWorkspace("workspace-a")]; got != "previous-workspace-token" { + t.Fatalf("workspace secret = %q, want previous-workspace-token", got) + } + if legacy.deleteCalls != 0 || legacy.values["legacy-token"] != "glpat-legacy" { + t.Fatal("legacy PAT was deleted before the workspace config became durable") + } + if hosts.host != "http://gitlab.internal" { + t.Fatalf("legacy host = %q, want unchanged", hosts.host) + } +} + func TestProvideFailsClosedWhenHostStoreCannotBeRead(t *testing.T) { t.Setenv("KANDEV_MOCK_GITLAB", "true") t.Setenv("GITLAB_TOKEN", "token-for-self-managed-host") diff --git a/apps/backend/internal/gitlab/security_regression_test.go b/apps/backend/internal/gitlab/security_regression_test.go new file mode 100644 index 0000000000..cbb3905c2c --- /dev/null +++ b/apps/backend/internal/gitlab/security_regression_test.go @@ -0,0 +1,289 @@ +package gitlab + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" + + "github.com/kandev/kandev/internal/common/logger" + ws "github.com/kandev/kandev/pkg/websocket" +) + +func TestRegisterRoutesWithDispatcherDoesNotExposeLegacyGitLabActions(t *testing.T) { + dispatcher := ws.NewDispatcher() + router := gin.New() + RegisterRoutesWithDispatcher( + router, + dispatcher, + NewService(DefaultHost, NewNoopClient(DefaultHost), AuthMethodNone, nil, newTestLogger(t)), + newTestLogger(t), + ) + + legacyActions := []string{ + ws.ActionGitLabStatus, + ws.ActionGitLabTaskMRsList, + ws.ActionGitLabTaskMRGet, + ws.ActionGitLabMRFeedbackGet, + ws.ActionGitLabReviewWatchesList, + ws.ActionGitLabReviewWatchCreate, + ws.ActionGitLabReviewWatchUpdate, + ws.ActionGitLabReviewWatchDelete, + ws.ActionGitLabReviewTrigger, + ws.ActionGitLabReviewTriggerAll, + ws.ActionGitLabMRWatchesList, + ws.ActionGitLabMRWatchDelete, + ws.ActionGitLabMRFilesGet, + ws.ActionGitLabMRCommitsGet, + ws.ActionGitLabTaskMRSync, + ws.ActionGitLabStats, + ws.ActionGitLabMRMerge, + ws.ActionGitLabMRApprove, + ws.ActionGitLabMRUnapprove, + ws.ActionGitLabMRSetLabels, + ws.ActionGitLabMRSetAssignees, + ws.ActionGitLabMRDiscussionNew, + ws.ActionGitLabMRDiscussionResolve, + ws.ActionGitLabProjectMergeMethodsGet, + ws.ActionGitLabIssueWatchesList, + ws.ActionGitLabIssueWatchCreate, + ws.ActionGitLabIssueWatchUpdate, + ws.ActionGitLabIssueWatchDelete, + ws.ActionGitLabIssueTrigger, + ws.ActionGitLabIssueTriggerAll, + ws.ActionGitLabActionPresetsList, + ws.ActionGitLabActionPresetsUpdate, + ws.ActionGitLabActionPresetsReset, + ws.ActionGitLabListUserProjects, + ws.ActionGitLabSearchProjects, + ws.ActionGitLabProjectBranches, + ws.ActionGitLabCleanupReviewTasks, + ws.ActionGitLabCleanupIssueTasks, + } + for _, action := range legacyActions { + if dispatcher.HasHandler(action) { + t.Errorf("legacy GitLab WebSocket action %q is registered", action) + } + } +} + +func TestRegisterHTTPRoutesDoesNotExposeUnscopedTaskMRSync(t *testing.T) { + router := gin.New() + NewController( + NewService(DefaultHost, NewNoopClient(DefaultHost), AuthMethodNone, nil, newTestLogger(t)), + newTestLogger(t), + ).RegisterHTTPRoutes(router) + + request := httptest.NewRequest( + http.MethodPost, + "/api/v1/gitlab/tasks/fabricated/mrs/sync", + strings.NewReader(`{"project_path":"group/project","iid":7}`), + ) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusNotFound { + t.Fatalf("sync route status = %d, want 404; body=%s", response.Code, response.Body.String()) + } +} + +func TestEnvironmentTokenRejectsUntrustedWorkspaceHostBeforeClientConstruction(t *testing.T) { + t.Setenv(secretNameToken, "glpat-environment-secret") + store := newTestStore(t) + seedWorkspace(t, store, "workspace-a") + service := newWorkspaceConfigService(t, store, &configTestSecrets{values: make(map[string]string)}) + var constructions atomic.Int32 + service.workspaceClientFn = func(_ context.Context, cfg *GitLabConfig, _ string) (Client, error) { + constructions.Add(1) + return NewMockClient(cfg.Host), nil + } + + result := service.TestConfigForWorkspace(t.Context(), "workspace-a", &SetConfigRequest{ + Host: "https://attacker.invalid", AuthMethod: AuthMethodEnvironment, + }) + if result.OK || !strings.Contains(result.Error, "environment credential host") { + t.Fatalf("test result = %#v, want trusted-origin rejection", result) + } + if got := constructions.Load(); got != 0 { + t.Fatalf("client constructions = %d, want zero", got) + } +} + +func TestEnvironmentTokenAllowsImmutableStartupHost(t *testing.T) { + t.Setenv(secretNameToken, "glpat-environment-secret") + store := newTestStore(t) + seedWorkspace(t, store, "workspace-a") + service := NewService("https://gitlab.internal", NewNoopClient(DefaultHost), AuthMethodNone, nil, newTestLogger(t)) + service.SetStore(store) + service.SetWorkspaceSecretStore(&configTestSecrets{values: make(map[string]string)}) + var constructions atomic.Int32 + service.workspaceClientFn = func(_ context.Context, cfg *GitLabConfig, token string) (Client, error) { + constructions.Add(1) + if cfg.Host != "https://gitlab.internal" || token != "glpat-environment-secret" { + t.Fatalf("constructed client for host=%q token=%q", cfg.Host, token) + } + return NewMockClient(cfg.Host), nil + } + + result := service.TestConfigForWorkspace(t.Context(), "workspace-a", &SetConfigRequest{ + Host: "https://gitlab.internal/", AuthMethod: AuthMethodEnvironment, + }) + if !result.OK || result.Error != "" { + t.Fatalf("test result = %#v, want success", result) + } + if got := constructions.Load(); got != 1 { + t.Fatalf("client constructions = %d, want one", got) + } +} + +func TestResolveExecutionCredentialsRejectsPersistedEnvironmentHostMismatch(t *testing.T) { + t.Setenv(secretNameToken, "glpat-environment-secret") + store := newTestStore(t) + seedWorkspace(t, store, "workspace-a") + if err := store.SaveConfigForWorkspace(t.Context(), "workspace-a", &GitLabConfig{ + Host: "https://attacker.invalid", AuthMethod: AuthMethodEnvironment, + }); err != nil { + t.Fatalf("seed mismatched config: %v", err) + } + service := NewService("https://gitlab.internal", NewNoopClient(DefaultHost), AuthMethodNone, nil, newTestLogger(t)) + service.SetStore(store) + + host, token, err := service.ResolveGitLabExecutionCredentials(t.Context(), "workspace-a") + if !errors.Is(err, ErrInvalidConfig) { + t.Fatalf("error = %v, want ErrInvalidConfig", err) + } + if host != "" || token != "" { + t.Fatalf("credentials = (%q, %q), want empty values", host, token) + } +} + +func TestAssociateExistingMRRejectsFabricatedProviderIdentityWithoutPersistence(t *testing.T) { + const host = "https://gitlab.internal" + tests := []struct { + name string + mr *MR + }{ + { + name: "wrong returned iid", + mr: &MR{IID: 8, WebURL: host + "/group/project/-/merge_requests/8"}, + }, + { + name: "wrong returned host", + mr: &MR{IID: 7, WebURL: "https://attacker.invalid/group/project/-/merge_requests/7"}, + }, + { + name: "wrong returned project path", + mr: &MR{IID: 7, WebURL: host + "/group/other/-/merge_requests/7"}, + }, + { + name: "malformed returned url", + mr: &MR{IID: 7, WebURL: "not-a-url"}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + service, store, client := newTaskMRLinkService(t, host) + seedTaskMRLinkFixture(t, store, "ws-1", "task-1", "repo-1") + setTaskMRRepositoryIdentity(t, store, "repo-1", host, "group/project") + test.mr.Title = "untrusted provider response" + test.mr.State = "opened" + test.mr.CreatedAt = time.Now().UTC() + service.workspaceClients["ws-1"] = &fixedMRStatusClient{ + Client: client, + status: &MRStatus{MR: test.mr}, + } + + _, err := service.AssociateExistingMRByURL( + t.Context(), "ws-1", "task-1", "repo-1", + host+"/group/project/-/merge_requests/7", + ) + if !errors.Is(err, ErrTaskMRNotFound) { + t.Fatalf("error = %v, want ErrTaskMRNotFound", err) + } + rows, listErr := store.ListTaskMRsByTask(t.Context(), "task-1") + if listErr != nil || len(rows) != 0 { + t.Fatalf("stored rows = %d, err = %v; want zero", len(rows), listErr) + } + }) + } +} + +type fixedMRStatusClient struct { + Client + status *MRStatus +} + +func (c *fixedMRStatusClient) GetMRStatus(context.Context, string, int) (*MRStatus, error) { + return c.status, nil +} + +func TestAPIErrorNeverIncludesProviderResponseBody(t *testing.T) { + const hostileBody = `{"message":"private@example.com glpat-secret"}` + err := (&APIError{StatusCode: http.StatusBadGateway, Endpoint: "/projects", Body: hostileBody}).Error() + if strings.Contains(err, hostileBody) || strings.Contains(err, "glpat-secret") { + t.Fatalf("APIError leaked provider response body: %q", err) + } +} + +func TestHostileProviderBodyDoesNotReachHTTPStatusOrLogs(t *testing.T) { + const secret = "glpat-secret-do-not-log" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message":"contact private@example.com with ` + secret + `"}`)) + })) + t.Cleanup(server.Close) + + core, observed := observer.New(zapcore.DebugLevel) + log, err := logger.NewFromZap(zap.New(core)) + if err != nil { + t.Fatalf("logger: %v", err) + } + store := newTestStore(t) + seedWorkspace(t, store, "workspace-test") + if err := store.SaveConfigForWorkspace(t.Context(), "workspace-test", &GitLabConfig{ + Host: server.URL, AuthMethod: AuthMethodPAT, + }); err != nil { + t.Fatalf("seed config: %v", err) + } + service := NewService(server.URL, NewPATClient(server.URL, secret), AuthMethodPAT, nil, log) + service.SetStore(store) + service.SetWorkspaceSecretStore(&configTestSecrets{values: map[string]string{ + SecretKeyForWorkspace("workspace-test"): secret, + }}) + router := gin.New() + NewController(service, log).RegisterHTTPRoutes(router) + + response := hit(router, "/api/v1/gitlab/user/mrs") + if response.Code != http.StatusBadGateway { + t.Fatalf("HTTP status = %d, want 502; body=%s", response.Code, response.Body.String()) + } + if strings.Contains(response.Body.String(), secret) || strings.Contains(response.Body.String(), "private@example.com") { + t.Fatalf("HTTP response leaked provider body: %s", response.Body.String()) + } + statusResponse := hit(router, "/api/v1/gitlab/status") + if statusResponse.Code != http.StatusOK { + t.Fatalf("status endpoint = %d, want 200; body=%s", statusResponse.Code, statusResponse.Body.String()) + } + if strings.Contains(statusResponse.Body.String(), secret) || strings.Contains(statusResponse.Body.String(), "private@example.com") { + t.Fatalf("status response leaked provider body: %s", statusResponse.Body.String()) + } + _, _ = service.GetStatsForWorkspace(t.Context(), "workspace-test") + for _, entry := range observed.All() { + serialized := entry.ContextMap() + contextText := fmt.Sprint(serialized) + if strings.Contains(entry.Message, secret) || strings.Contains(entry.Message, "private@example.com") || + strings.Contains(contextText, secret) || strings.Contains(contextText, "private@example.com") { + t.Fatalf("log leaked provider body: message=%q context=%v", entry.Message, serialized) + } + } +} diff --git a/apps/backend/internal/gitlab/service.go b/apps/backend/internal/gitlab/service.go index 5eff032fb5..ab86960464 100644 --- a/apps/backend/internal/gitlab/service.go +++ b/apps/backend/internal/gitlab/service.go @@ -12,7 +12,9 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/common/securityutil" "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/watchreset" ) // secretNameToken is the canonical secret-store name for the GitLab PAT. @@ -32,6 +34,14 @@ type SecretManager interface { Delete(ctx context.Context, id string) error } +// WorkspaceSecretStore persists deterministic workspace-owned secret IDs. +type WorkspaceSecretStore interface { + Reveal(ctx context.Context, id string) (string, error) + Set(ctx context.Context, id, name, value string) error + Delete(ctx context.Context, id string) error + Exists(ctx context.Context, id string) (bool, error) +} + // HostStore persists the configured GitLab host. type HostStore interface { GetHost(ctx context.Context) (string, error) @@ -56,20 +66,44 @@ type TaskSessionChecker interface { HasUserAuthoredMessage(ctx context.Context, taskID string) (bool, error) } +// RepositoryLookup validates optional watch repository bindings. +type RepositoryLookup interface { + GetRepository(ctx context.Context, id string) (workspaceID, defaultBranch string, ok bool) +} + +// WatchDependencyValidator validates the non-repository references stored on +// an automation watch. Workflow steps are checked together with their parent +// workflow so a valid step from another workflow cannot be smuggled in. +type WatchDependencyValidator interface { + WorkflowStepBelongs(ctx context.Context, workspaceID, workflowID, stepID string) (bool, error) + AgentProfileBelongs(ctx context.Context, workspaceID, profileID string) (bool, error) + ExecutorProfileBelongs(ctx context.Context, workspaceID, profileID string) (bool, error) +} + // Service coordinates GitLab integration operations. type Service struct { - mu sync.RWMutex - host string - client Client - authMethod string - secrets SecretProvider - secretManager SecretManager - hostStore HostStore - store *Store - eventBus bus.EventBus - taskDeleter TaskDeleter - taskSessionChecker TaskSessionChecker - logger *logger.Logger + mu sync.RWMutex + configMutationMu sync.Mutex + host string + client Client + authMethod string + secrets SecretProvider + secretManager SecretManager + workspaceSecrets WorkspaceSecretStore + workspaceClientFn WorkspaceClientFactory + glabTokenFn func(context.Context, string) (string, error) + workspaceClients map[string]Client + workspaceClientRevs map[string]int64 + environmentTokenHost string + hostStore HostStore + store *Store + eventBus bus.EventBus + taskDeleter TaskDeleter + cascadeTaskDeleter watchreset.TaskDeleter + taskSessionChecker TaskSessionChecker + repositoryLookup RepositoryLookup + dependencyValidator WatchDependencyValidator + logger *logger.Logger } // SetEventBus wires the event bus for publishing review/issue/feedback events. @@ -86,6 +120,13 @@ func (s *Service) SetTaskDeleter(d TaskDeleter) { s.taskDeleter = d } +// SetCascadeTaskDeleter wires destructive watch reset to task tree deletion. +func (s *Service) SetCascadeTaskDeleter(d watchreset.TaskDeleter) { + s.mu.Lock() + defer s.mu.Unlock() + s.cascadeTaskDeleter = d +} + // SetTaskSessionChecker wires the user-engagement check used by cleanup. func (s *Service) SetTaskSessionChecker(c TaskSessionChecker) { s.mu.Lock() @@ -93,6 +134,72 @@ func (s *Service) SetTaskSessionChecker(c TaskSessionChecker) { s.taskSessionChecker = c } +func (s *Service) SetRepositoryLookup(lookup RepositoryLookup) { + s.mu.Lock() + defer s.mu.Unlock() + s.repositoryLookup = lookup +} + +func (s *Service) SetWatchDependencyValidator(validator WatchDependencyValidator) { + s.mu.Lock() + defer s.mu.Unlock() + s.dependencyValidator = validator +} + +func (s *Service) validateWatchDependencies(ctx context.Context, workspaceID, workflowID, stepID, agentProfileID, executorProfileID string) error { + if strings.TrimSpace(workspaceID) == "" || strings.TrimSpace(workflowID) == "" || + strings.TrimSpace(stepID) == "" || strings.TrimSpace(agentProfileID) == "" || + strings.TrimSpace(executorProfileID) == "" { + return fmt.Errorf("%w: invalid watch dependencies", ErrInvalidConfig) + } + s.mu.RLock() + validator := s.dependencyValidator + s.mu.RUnlock() + if validator == nil { + return nil + } + checks := []func() (bool, error){ + func() (bool, error) { return validator.WorkflowStepBelongs(ctx, workspaceID, workflowID, stepID) }, + func() (bool, error) { return validator.AgentProfileBelongs(ctx, workspaceID, agentProfileID) }, + func() (bool, error) { return validator.ExecutorProfileBelongs(ctx, workspaceID, executorProfileID) }, + } + for _, check := range checks { + valid, err := check() + if err != nil { + return fmt.Errorf("validate watch dependencies: %w", err) + } + if !valid { + return fmt.Errorf("%w: invalid watch dependencies", ErrInvalidConfig) + } + } + return nil +} + +func (s *Service) resolveWatchRepository(ctx context.Context, workspaceID, repositoryID, baseBranch string) (string, string, error) { + repositoryID = strings.TrimSpace(repositoryID) + baseBranch = strings.TrimSpace(baseBranch) + if repositoryID == "" { + return "", "", nil + } + if baseBranch != "" && !securityutil.IsValidBaseBranchRef(baseBranch) { + return "", "", fmt.Errorf("%w: base branch %q is not a valid git ref", ErrInvalidConfig, baseBranch) + } + s.mu.RLock() + lookup := s.repositoryLookup + s.mu.RUnlock() + if lookup == nil { + return repositoryID, baseBranch, nil + } + repoWorkspace, defaultBranch, ok := lookup.GetRepository(ctx, repositoryID) + if !ok || repoWorkspace != workspaceID { + return "", "", fmt.Errorf("%w: repository %q not found in workspace", ErrInvalidConfig, repositoryID) + } + if baseBranch == "" { + baseBranch = defaultBranch + } + return repositoryID, baseBranch, nil +} + // SetStore wires the task↔MR persistence layer. Optional — when nil the // task-mr endpoints return empty results and SyncTaskMR is a no-op. func (s *Service) SetStore(store *Store) { @@ -101,18 +208,30 @@ func (s *Service) SetStore(store *Store) { s.store = store } +// SetWorkspaceSecretStore wires deterministic per-workspace credential storage. +func (s *Service) SetWorkspaceSecretStore(secrets WorkspaceSecretStore) { + s.mu.Lock() + defer s.mu.Unlock() + s.workspaceSecrets = secrets +} + // NewService builds a Service from an already-resolved client. Callers // typically use Provide() instead of constructing this directly. func NewService(host string, client Client, authMethod string, secrets SecretProvider, log *logger.Logger) *Service { if host == "" { host = DefaultHost } + trustedEnvironmentHost := trustedEnvironmentTokenHost(host) return &Service{ - host: host, - client: client, - authMethod: authMethod, - secrets: secrets, - logger: log, + host: host, + client: client, + authMethod: authMethod, + secrets: secrets, + workspaceClients: make(map[string]Client), + workspaceClientRevs: make(map[string]int64), + environmentTokenHost: trustedEnvironmentHost, + glabTokenFn: glabReadToken, + logger: log, } } @@ -192,7 +311,7 @@ func (s *Service) GetStatus(ctx context.Context) (*Status, error) { // failure (network, 5xx, parse error) the user needs to see as // "GitLab unreachable" rather than "not connected", so they don't // delete a valid token during a transient outage. - status.ConnectionError = authErr.Error() + status.ConnectionError = connectionUnavailable } if g, ok := client.(*GLabClient); ok { status.GLabVersion = g.Version() @@ -385,16 +504,19 @@ func (s *Service) findTokenSecret(ctx context.Context) (bool, string, error) { // projectPath is the GitLab namespace/path. iid is the MR's per-project id. func (s *Service) SyncTaskMR(ctx context.Context, taskID, repositoryID, projectPath string, iid int) (*TaskMR, error) { s.mu.RLock() - client := s.client store := s.store - host := s.host s.mu.RUnlock() if store == nil { return nil, errors.New("gitlab store not configured") } + client, err := s.clientForTask(ctx, taskID) + if err != nil { + return nil, err + } if client == nil { return nil, ErrNoClient } + host := client.Host() status, err := client.GetMRStatus(ctx, projectPath, iid) if err != nil { return nil, fmt.Errorf("fetch MR status: %w", err) diff --git a/apps/backend/internal/gitlab/service_cleanup.go b/apps/backend/internal/gitlab/service_cleanup.go index 9275b640ea..ec21b33fd9 100644 --- a/apps/backend/internal/gitlab/service_cleanup.go +++ b/apps/backend/internal/gitlab/service_cleanup.go @@ -36,6 +36,31 @@ func (s *Service) CleanupAllReviewTasks(ctx context.Context) (int, error) { return s.sweepReviewMRTasks(ctx, tasks, deleter, checker), nil } +// CleanupReviewTasksForWorkspace sweeps only tasks owned by review watches in +// workspaceID and resolves that workspace's GitLab client. +func (s *Service) CleanupReviewTasksForWorkspace(ctx context.Context, workspaceID string) (int, error) { + store := s.requireStore() + if store == nil { + return 0, errStoreUnavailable + } + s.mu.RLock() + deleter := s.taskDeleter + checker := s.taskSessionChecker + s.mu.RUnlock() + if deleter == nil { + return 0, errors.New("task deleter not configured") + } + tasks, err := store.ListReviewMRTasksForWorkspace(ctx, workspaceID) + if err != nil { + return 0, err + } + client, err := s.ClientForWorkspace(ctx, workspaceID) + if err != nil { + return 0, err + } + return s.sweepReviewMRTasksWithClient(ctx, tasks, client, deleter, checker), nil +} + // deleteTaskWithReason deletes a task, threading the cleanup reason through to // the task.deleted event when the wired deleter supports it (see // TaskDeleterWithReason). Falls back to plain DeleteTask otherwise. @@ -51,6 +76,10 @@ func (s *Service) sweepReviewMRTasks(ctx context.Context, tasks []*ReviewMRTask, if client == nil { return 0 } + return s.sweepReviewMRTasksWithClient(ctx, tasks, client, deleter, checker) +} + +func (s *Service) sweepReviewMRTasksWithClient(ctx context.Context, tasks []*ReviewMRTask, client Client, deleter TaskDeleter, checker TaskSessionChecker) int { policies := s.resolveReviewWatchPolicies(ctx, tasks) deleted := 0 for _, t := range tasks { @@ -149,11 +178,40 @@ func (s *Service) CleanupAllIssueTasks(ctx context.Context) (int, error) { return s.sweepIssueWatchTasks(ctx, tasks, deleter, checker), nil } +// CleanupIssueTasksForWorkspace sweeps only tasks owned by issue watches in +// workspaceID and resolves that workspace's GitLab client. +func (s *Service) CleanupIssueTasksForWorkspace(ctx context.Context, workspaceID string) (int, error) { + store := s.requireStore() + if store == nil { + return 0, errStoreUnavailable + } + s.mu.RLock() + deleter := s.taskDeleter + checker := s.taskSessionChecker + s.mu.RUnlock() + if deleter == nil { + return 0, errors.New("task deleter not configured") + } + tasks, err := store.ListIssueWatchTasksForWorkspace(ctx, workspaceID) + if err != nil { + return 0, err + } + client, err := s.ClientForWorkspace(ctx, workspaceID) + if err != nil { + return 0, err + } + return s.sweepIssueWatchTasksWithClient(ctx, tasks, client, deleter, checker), nil +} + func (s *Service) sweepIssueWatchTasks(ctx context.Context, tasks []*IssueWatchTask, deleter TaskDeleter, checker TaskSessionChecker) int { client := s.Client() if client == nil { return 0 } + return s.sweepIssueWatchTasksWithClient(ctx, tasks, client, deleter, checker) +} + +func (s *Service) sweepIssueWatchTasksWithClient(ctx context.Context, tasks []*IssueWatchTask, client Client, deleter TaskDeleter, checker TaskSessionChecker) int { policies := s.resolveIssueWatchPolicies(ctx, tasks) deleted := 0 for _, t := range tasks { diff --git a/apps/backend/internal/gitlab/service_config.go b/apps/backend/internal/gitlab/service_config.go new file mode 100644 index 0000000000..04171acc7e --- /dev/null +++ b/apps/backend/internal/gitlab/service_config.go @@ -0,0 +1,755 @@ +package gitlab + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "time" + + "go.uber.org/zap" +) + +// WorkspaceClientFactory constructs a client from one workspace's resolved +// config and credential. Tests replace it to avoid external GitLab calls. +type WorkspaceClientFactory func(context.Context, *GitLabConfig, string) (Client, error) + +var ErrWorkspaceRequired = errors.New("workspace_id required") +var ErrInvalidConfig = errors.New("gitlab: invalid configuration") +var ErrNotConfigured = errors.New("gitlab: workspace not configured") +var ErrWorkspaceHostMismatch = errors.New("gitlab: workspace host mismatch") + +const ( + envGitLabHost = "GITLAB_HOST" + envKandevGitLabHost = "KANDEV_GITLAB_HOST" +) + +// ResolveGitLabExecutionCredentials returns credentials for exactly one +// configured workspace. It is consumed by the orchestrator executor and never +// falls back to another workspace's connection. +func (s *Service) ResolveGitLabExecutionCredentials(ctx context.Context, workspaceID string) (string, string, error) { + cfg, err := s.GetConfigForWorkspace(ctx, workspaceID) + if err != nil { + return "", "", err + } + if cfg == nil { + return "", "", ErrNotConfigured + } + if err := s.validateEnvironmentTokenHost(cfg.AuthMethod, cfg.Host); err != nil { + return "", "", err + } + if cfg.AuthMethod == AuthMethodGLab { + s.mu.RLock() + resolve := s.glabTokenFn + s.mu.RUnlock() + if resolve == nil { + return "", "", fmt.Errorf("%w: glab credential resolver unavailable", ErrInvalidConfig) + } + token, resolveErr := resolve(ctx, stripScheme(cfg.Host)) + if resolveErr != nil || strings.TrimSpace(token) == "" { + if resolveErr == nil { + resolveErr = errors.New("empty token") + } + return "", "", fmt.Errorf("resolve glab token for %s: %w", cfg.Host, resolveErr) + } + return cfg.Host, strings.TrimSpace(token), nil + } + token, err := s.resolveWorkspaceToken(ctx, workspaceID, cfg.AuthMethod, "") + if err != nil { + return "", "", err + } + return cfg.Host, token, nil +} + +// GetConfigForWorkspace returns public connection metadata for one workspace. +func (s *Service) GetConfigForWorkspace(ctx context.Context, workspaceID string) (*GitLabConfig, error) { + workspaceID = strings.TrimSpace(workspaceID) + if workspaceID == "" { + return nil, ErrWorkspaceRequired + } + s.mu.RLock() + store := s.store + secrets := s.workspaceSecrets + s.mu.RUnlock() + if store == nil { + return nil, errors.New("gitlab store not configured") + } + cfg, err := store.GetConfigForWorkspace(ctx, workspaceID) + if err != nil || cfg == nil || secrets == nil { + return cfg, err + } + cfg.HasSecret, err = secrets.Exists(ctx, SecretKeyForWorkspace(workspaceID)) + return cfg, err +} + +// SetConfigForWorkspace validates credentials before replacing the saved +// connection, so a failed probe leaves the previous row and secret untouched. +func (s *Service) SetConfigForWorkspace(ctx context.Context, workspaceID string, req *SetConfigRequest) (*GitLabConfig, error) { + workspaceID = strings.TrimSpace(workspaceID) + if workspaceID == "" { + return nil, ErrWorkspaceRequired + } + cfg, token, err := s.resolveConfigRequest(ctx, workspaceID, req) + if err != nil { + return nil, err + } + client, err := s.buildWorkspaceClient(ctx, cfg, token) + if err != nil { + return nil, err + } + username, err := client.GetAuthenticatedUser(ctx) + if err != nil || username == "" { + if err == nil { + err = errors.New("GitLab returned an empty username") + } + return nil, fmt.Errorf("test GitLab connection: %w", err) + } + cfg.Username = username + if err := s.persistWorkspaceConfig(ctx, workspaceID, cfg, token); err != nil { + return nil, err + } + return s.GetConfigForWorkspace(ctx, workspaceID) +} + +// TestConfigForWorkspace probes supplied or stored settings without persisting. +func (s *Service) TestConfigForWorkspace(ctx context.Context, workspaceID string, req *SetConfigRequest) *TestConnectionResult { + cfg, token, err := s.resolveConfigRequest(ctx, strings.TrimSpace(workspaceID), req) + if err != nil { + return &TestConnectionResult{Error: publicConfigError(err)} + } + client, err := s.buildWorkspaceClient(ctx, cfg, token) + if err != nil { + return &TestConnectionResult{Error: "GitLab connection test failed"} + } + username, err := client.GetAuthenticatedUser(ctx) + if err != nil { + return &TestConnectionResult{Error: "GitLab connection test failed"} + } + return &TestConnectionResult{OK: username != "", Username: username} +} + +func publicConfigError(err error) string { + switch { + case errors.Is(err, ErrWorkspaceRequired): + return "workspace_id required" + case errors.Is(err, ErrInvalidConfig): + return err.Error() + default: + return "GitLab connection test failed" + } +} + +func (s *Service) resolveConfigRequest(ctx context.Context, workspaceID string, req *SetConfigRequest) (*GitLabConfig, string, error) { + if workspaceID == "" { + return nil, "", ErrWorkspaceRequired + } + if req == nil { + return nil, "", fmt.Errorf("%w: request required", ErrInvalidConfig) + } + host := strings.TrimSpace(req.Host) + authMethod := strings.TrimSpace(req.AuthMethod) + host, authMethod, err := s.configRequestDefaults(ctx, workspaceID, host, authMethod) + if err != nil { + return nil, "", err + } + normalizedHost, err := normalizeHostOrigin(host) + if err != nil { + return nil, "", fmt.Errorf("%w: %s", ErrInvalidConfig, err) + } + if !validWorkspaceAuthMethod(authMethod) { + return nil, "", fmt.Errorf("%w: unsupported auth_method %q", ErrInvalidConfig, authMethod) + } + if err := s.validateEnvironmentTokenHost(authMethod, normalizedHost); err != nil { + return nil, "", err + } + token, err := s.resolveWorkspaceToken(ctx, workspaceID, authMethod, strings.TrimSpace(req.Token)) + if err != nil { + return nil, "", err + } + return &GitLabConfig{WorkspaceID: workspaceID, Host: normalizedHost, AuthMethod: authMethod}, token, nil +} + +func (s *Service) configRequestDefaults(ctx context.Context, workspaceID, host, authMethod string) (string, string, error) { + if host != "" && authMethod != "" { + return host, authMethod, nil + } + stored, err := s.GetConfigForWorkspace(ctx, workspaceID) + if err != nil { + return "", "", err + } + if stored == nil { + return host, authMethod, nil + } + if host == "" { + host = stored.Host + } + if authMethod == "" { + authMethod = stored.AuthMethod + } + return host, authMethod, nil +} + +func normalizeHostOrigin(host string) (string, error) { + parsed, err := validateHost(strings.TrimSpace(host)) + if err != nil { + return "", err + } + if parsed.Path != "" { + return "", errors.New("host must be an HTTP(S) origin without a path") + } + return parsed.String(), nil +} + +func validWorkspaceAuthMethod(method string) bool { + switch method { + case AuthMethodPAT, AuthMethodGLab, AuthMethodEnvironment: + return true + default: + return false + } +} + +func trustedEnvironmentTokenHost(startupHost string) string { + host := strings.TrimSpace(os.Getenv(envKandevGitLabHost)) + if host == "" { + host = strings.TrimSpace(os.Getenv(envGitLabHost)) + } + if host == "" { + host = startupHost + } + normalized, err := normalizeHostOrigin(host) + if err != nil { + return "" + } + return normalized +} + +func (s *Service) validateEnvironmentTokenHost(authMethod, host string) error { + if authMethod != AuthMethodEnvironment { + return nil + } + s.mu.RLock() + trustedHost := s.environmentTokenHost + s.mu.RUnlock() + if trustedHost == "" || !strings.EqualFold(host, trustedHost) { + return fmt.Errorf("%w: environment credential host must match the trusted startup GitLab origin", ErrInvalidConfig) + } + return nil +} + +func (s *Service) resolveWorkspaceToken(ctx context.Context, workspaceID, authMethod, supplied string) (string, error) { + if authMethod == AuthMethodEnvironment { + token := strings.TrimSpace(os.Getenv(secretNameToken)) + if token == "" { + return "", fmt.Errorf("%w: GITLAB_TOKEN is empty", ErrInvalidConfig) + } + return token, nil + } + if authMethod != AuthMethodPAT { + return "", nil + } + if supplied != "" { + return supplied, nil + } + s.mu.RLock() + secrets := s.workspaceSecrets + s.mu.RUnlock() + if secrets == nil { + return "", fmt.Errorf("%w: token required", ErrInvalidConfig) + } + token, err := secrets.Reveal(ctx, SecretKeyForWorkspace(workspaceID)) + if err != nil || strings.TrimSpace(token) == "" { + return "", fmt.Errorf("%w: token required", ErrInvalidConfig) + } + return strings.TrimSpace(token), nil +} + +func (s *Service) persistWorkspaceConfig(ctx context.Context, workspaceID string, cfg *GitLabConfig, token string) error { + s.configMutationMu.Lock() + defer s.configMutationMu.Unlock() + + s.mu.RLock() + store := s.store + secrets := s.workspaceSecrets + s.mu.RUnlock() + return s.persistWorkspaceConfigLocked(ctx, store, secrets, workspaceID, cfg, token) +} + +func (s *Service) persistWorkspaceConfigLocked(ctx context.Context, store *Store, secrets WorkspaceSecretStore, workspaceID string, cfg *GitLabConfig, token string) error { + if store == nil { + return errors.New("gitlab store not configured") + } + snapshot, err := snapshotWorkspaceConnection(ctx, store, secrets, workspaceID) + if err != nil { + return fmt.Errorf("snapshot GitLab config: %w", err) + } + if err := persistWorkspaceSecret(ctx, secrets, workspaceID, cfg.AuthMethod, token, snapshot.secret); err != nil { + return rollbackWorkspaceConnection(ctx, store, secrets, workspaceID, snapshot, fmt.Errorf("store GitLab token: %w", err)) + } + if err := store.SaveConfigForWorkspace(ctx, workspaceID, cfg); err != nil { + return rollbackWorkspaceConnection(ctx, store, secrets, workspaceID, snapshot, fmt.Errorf("store GitLab config: %w", err)) + } + s.invalidateWorkspaceClient(workspaceID) + return nil +} + +type workspaceSecretSnapshot struct { + exists bool + value string +} + +type workspaceConnectionSnapshot struct { + config *GitLabConfig + secret workspaceSecretSnapshot +} + +func snapshotWorkspaceConnection(ctx context.Context, store *Store, secrets WorkspaceSecretStore, workspaceID string) (workspaceConnectionSnapshot, error) { + cfg, err := store.GetConfigForWorkspace(ctx, workspaceID) + if err != nil { + return workspaceConnectionSnapshot{}, err + } + secret, err := snapshotWorkspaceSecret(ctx, secrets, SecretKeyForWorkspace(workspaceID)) + if err != nil { + return workspaceConnectionSnapshot{}, err + } + return workspaceConnectionSnapshot{config: cfg, secret: secret}, nil +} + +func snapshotWorkspaceSecret(ctx context.Context, secrets WorkspaceSecretStore, key string) (workspaceSecretSnapshot, error) { + if secrets == nil { + return workspaceSecretSnapshot{}, nil + } + exists, err := secrets.Exists(ctx, key) + if err != nil || !exists { + return workspaceSecretSnapshot{}, err + } + value, err := secrets.Reveal(ctx, key) + if err != nil { + return workspaceSecretSnapshot{}, err + } + return workspaceSecretSnapshot{exists: true, value: value}, nil +} + +func persistWorkspaceSecret(ctx context.Context, secrets WorkspaceSecretStore, workspaceID, authMethod, token string, previous workspaceSecretSnapshot) error { + if authMethod == AuthMethodPAT { + if secrets == nil { + return errors.New("gitlab secret store not configured") + } + return secrets.Set(ctx, SecretKeyForWorkspace(workspaceID), "GitLab token", token) + } + if secrets != nil && previous.exists { + return secrets.Delete(ctx, SecretKeyForWorkspace(workspaceID)) + } + return nil +} + +func rollbackWorkspaceConnection(ctx context.Context, store *Store, secrets WorkspaceSecretStore, workspaceID string, snapshot workspaceConnectionSnapshot, cause error) error { + rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + + var rollbackErrs []error + current, err := store.GetConfigForWorkspace(rollbackCtx, workspaceID) + if err != nil { + rollbackErrs = append(rollbackErrs, fmt.Errorf("read GitLab config during rollback: %w", err)) + } else if !sameGitLabConfig(current, snapshot.config) { + if err := store.RestoreConfigForWorkspace(rollbackCtx, workspaceID, snapshot.config); err != nil { + rollbackErrs = append(rollbackErrs, fmt.Errorf("restore GitLab config: %w", err)) + } + } + if err := restoreWorkspaceSecret(rollbackCtx, secrets, SecretKeyForWorkspace(workspaceID), snapshot.secret); err != nil { + rollbackErrs = append(rollbackErrs, fmt.Errorf("restore GitLab token: %w", err)) + } + if len(rollbackErrs) == 0 { + return cause + } + return errors.Join(append([]error{cause}, rollbackErrs...)...) +} + +func restoreWorkspaceSecret(ctx context.Context, secrets WorkspaceSecretStore, key string, snapshot workspaceSecretSnapshot) error { + if secrets == nil { + return nil + } + if snapshot.exists { + return secrets.Set(ctx, key, "GitLab token", snapshot.value) + } + exists, err := secrets.Exists(ctx, key) + if err != nil || !exists { + return err + } + return secrets.Delete(ctx, key) +} + +func sameGitLabConfig(left, right *GitLabConfig) bool { + if left == nil || right == nil { + return left == nil && right == nil + } + return left.WorkspaceID == right.WorkspaceID && + left.Host == right.Host && + left.AuthMethod == right.AuthMethod && + left.Username == right.Username && + left.LastOK == right.LastOK && + left.LastError == right.LastError && + left.Revision == right.Revision && + sameTimePointer(left.LastCheckedAt, right.LastCheckedAt) && + left.CreatedAt.Equal(right.CreatedAt) && + left.UpdatedAt.Equal(right.UpdatedAt) +} + +func sameTimePointer(left, right *time.Time) bool { + if left == nil || right == nil { + return left == nil && right == nil + } + return left.Equal(*right) +} + +func (s *Service) buildWorkspaceClient(ctx context.Context, cfg *GitLabConfig, token string) (Client, error) { + s.mu.RLock() + factory := s.workspaceClientFn + log := s.logger + s.mu.RUnlock() + if factory != nil { + return factory(ctx, cfg, token) + } + if os.Getenv("KANDEV_MOCK_GITLAB") == "true" { + return NewMockClient(cfg.Host), nil + } + switch cfg.AuthMethod { + case AuthMethodPAT, AuthMethodEnvironment: + return NewPATClient(cfg.Host, token), nil + case AuthMethodGLab: + client, err := NewGLabClient(ctx, cfg.Host) + if err != nil && log != nil { + log.Debug("glab CLI unavailable for workspace connection") + } + return client, err + default: + return nil, fmt.Errorf("%w: unsupported auth_method %q", ErrInvalidConfig, cfg.AuthMethod) + } +} + +// ClientForWorkspace resolves and caches only the requested workspace client. +func (s *Service) ClientForWorkspace(ctx context.Context, workspaceID string) (Client, error) { + return s.ClientForWorkspaceHost(ctx, workspaceID, "") +} + +func (s *Service) ClientForWorkspaceHost(ctx context.Context, workspaceID, expectedHost string) (Client, error) { + workspaceID = strings.TrimSpace(workspaceID) + if workspaceID == "" { + return nil, ErrWorkspaceRequired + } + expectedHost = strings.TrimSpace(expectedHost) + if expectedHost != "" { + normalized, err := normalizeHostOrigin(expectedHost) + if err != nil { + return nil, ErrWorkspaceHostMismatch + } + expectedHost = normalized + } + for { + client, retry, err := s.resolveWorkspaceClientRevision(ctx, workspaceID, expectedHost) + if err != nil || !retry { + return client, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + } +} + +// RunWithWorkspaceClient resolves the configured host, credential, and client +// from one mutation-locked snapshot. Client construction, and the upstream +// action itself, run without holding the process-wide config mutation lock. +func (s *Service) RunWithWorkspaceClient( + ctx context.Context, + workspaceID, expectedHost string, + action func(Client) error, +) error { + client, err := s.ClientForWorkspaceHost(ctx, workspaceID, expectedHost) + if err != nil { + return err + } + return action(client) +} + +func (s *Service) resolveWorkspaceClientRevision( + ctx context.Context, workspaceID, expectedHost string, +) (Client, bool, error) { + if expectedHost == "" { + s.mu.RLock() + cached := s.workspaceClients[workspaceID] + revision := s.workspaceClientRevs[workspaceID] + s.mu.RUnlock() + if cached != nil && revision == 0 { + return cached, false, nil + } + } + snapshot, err := s.snapshotWorkspaceClientRevision(ctx, workspaceID) + if err != nil { + return nil, false, err + } + if snapshot.config == nil { + return nil, false, ErrNotConfigured + } + if expectedHost != "" && !strings.EqualFold(snapshot.config.Host, expectedHost) { + return nil, false, ErrWorkspaceHostMismatch + } + s.mu.RLock() + cached := s.workspaceClients[workspaceID] + cachedRevision := s.workspaceClientRevs[workspaceID] + s.mu.RUnlock() + if cached != nil && cachedRevision == snapshot.config.Revision && + strings.EqualFold(cached.Host(), snapshot.config.Host) { + return cached, false, nil + } + token, err := s.tokenForWorkspaceSnapshot(snapshot) + if err != nil { + return nil, false, err + } + client, err := s.buildWorkspaceClient(ctx, snapshot.config, token) + if err != nil { + return nil, false, err + } + published, current, err := s.publishWorkspaceClientRevision(ctx, workspaceID, snapshot.config, client) + if err != nil { + return nil, false, err + } + return published, !current, nil +} + +func (s *Service) snapshotWorkspaceClientRevision( + ctx context.Context, workspaceID string, +) (workspaceConnectionSnapshot, error) { + s.configMutationMu.Lock() + defer s.configMutationMu.Unlock() + s.mu.RLock() + store := s.store + secrets := s.workspaceSecrets + s.mu.RUnlock() + if store == nil { + return workspaceConnectionSnapshot{}, errors.New("gitlab store not configured") + } + return snapshotWorkspaceConnection(ctx, store, secrets, workspaceID) +} + +func (s *Service) publishWorkspaceClientRevision( + ctx context.Context, + workspaceID string, + snapshot *GitLabConfig, + client Client, +) (Client, bool, error) { + s.configMutationMu.Lock() + defer s.configMutationMu.Unlock() + s.mu.RLock() + store := s.store + s.mu.RUnlock() + if store == nil { + return nil, false, errors.New("gitlab store not configured") + } + current, err := store.GetConfigForWorkspace(ctx, workspaceID) + if err != nil { + return nil, false, err + } + if current == nil || current.Revision != snapshot.Revision || + !strings.EqualFold(current.Host, snapshot.Host) { + return nil, false, nil + } + s.mu.Lock() + defer s.mu.Unlock() + if s.workspaceClients == nil { + s.workspaceClients = make(map[string]Client) + } + if s.workspaceClientRevs == nil { + s.workspaceClientRevs = make(map[string]int64) + } + if existing := s.workspaceClients[workspaceID]; existing != nil && + s.workspaceClientRevs[workspaceID] == snapshot.Revision { + return existing, true, nil + } + s.workspaceClients[workspaceID] = client + s.workspaceClientRevs[workspaceID] = snapshot.Revision + return client, true, nil +} + +// GetStatusForWorkspace probes and reports only the selected connection. +func (s *Service) GetStatusForWorkspace(ctx context.Context, workspaceID string) (*Status, error) { + cfg, err := s.GetConfigForWorkspace(ctx, workspaceID) + if err != nil { + return nil, err + } + if cfg == nil { + return &Status{Host: DefaultHost, AuthMethod: AuthMethodNone, RequiredScopes: []string{"api", "read_user"}}, nil + } + status := &Status{ + Username: cfg.Username, + AuthMethod: cfg.AuthMethod, + Host: cfg.Host, + TokenConfigured: cfg.HasSecret, + RequiredScopes: []string{"api", "read_user"}, + } + client, clientErr := s.ClientForWorkspace(ctx, workspaceID) + if clientErr != nil { + status.ConnectionError = connectionUnavailable + return status, nil + } + status.Authenticated, clientErr = client.IsAuthenticated(ctx) + if clientErr != nil { + status.ConnectionError = connectionUnavailable + } + if status.Authenticated { + status.Username, _ = client.GetAuthenticatedUser(ctx) + } + if glab, ok := client.(*GLabClient); ok { + status.GLabVersion = glab.Version() + } + return status, nil +} + +func (s *Service) invalidateWorkspaceClient(workspaceID string) { + s.mu.Lock() + delete(s.workspaceClients, workspaceID) + delete(s.workspaceClientRevs, workspaceID) + s.mu.Unlock() +} + +func (s *Service) clientForTask(ctx context.Context, taskID string) (Client, error) { + s.mu.RLock() + store := s.store + legacyOnly := s.workspaceSecrets == nil + s.mu.RUnlock() + if store == nil { + return s.Client(), nil + } + if legacyOnly { + return s.Client(), nil + } + workspaceID, err := store.WorkspaceIDForTask(ctx, taskID) + if err != nil { + return nil, err + } + return s.ClientForWorkspace(ctx, workspaceID) +} + +// DeleteConfigForWorkspace deletes one workspace's metadata and PAT. +func (s *Service) DeleteConfigForWorkspace(ctx context.Context, workspaceID string) error { + workspaceID = strings.TrimSpace(workspaceID) + if workspaceID == "" { + return ErrWorkspaceRequired + } + s.configMutationMu.Lock() + defer s.configMutationMu.Unlock() + + s.mu.RLock() + store := s.store + secrets := s.workspaceSecrets + s.mu.RUnlock() + if store == nil { + return errors.New("gitlab store not configured") + } + snapshot, err := snapshotWorkspaceConnection(ctx, store, secrets, workspaceID) + if err != nil { + return fmt.Errorf("snapshot GitLab config: %w", err) + } + if secrets != nil && snapshot.secret.exists { + if err := secrets.Delete(ctx, SecretKeyForWorkspace(workspaceID)); err != nil { + return rollbackWorkspaceConnection(ctx, store, secrets, workspaceID, snapshot, fmt.Errorf("delete GitLab token: %w", err)) + } + } + if err := store.DeleteConfigForWorkspace(ctx, workspaceID); err != nil { + return rollbackWorkspaceConnection(ctx, store, secrets, workspaceID, snapshot, fmt.Errorf("delete GitLab config: %w", err)) + } + s.invalidateWorkspaceClient(workspaceID) + return nil +} + +// RecordWorkspaceAuthHealth probes every configured workspace independently +// and persists only sanitized status. One broken connection never prevents +// later workspace rows from being checked. +func (s *Service) RecordWorkspaceAuthHealth(ctx context.Context) { + s.mu.RLock() + store := s.store + s.mu.RUnlock() + if store == nil { + return + } + workspaceIDs, err := store.ListConfigWorkspaceIDs(ctx) + if err != nil { + s.logger.Warn("gitlab health: list configured workspaces", zap.Error(err)) + return + } + for _, workspaceID := range workspaceIDs { + if ctx.Err() != nil { + return + } + s.recordWorkspaceAuthHealth(ctx, store, workspaceID) + } +} + +func (s *Service) recordWorkspaceAuthHealth(ctx context.Context, store *Store, workspaceID string) { + checkedAt := time.Now().UTC() + client, revision, err := s.clientForWorkspaceRevision(ctx, store, workspaceID) + if err != nil { + _ = s.updateConfigHealth(ctx, store, workspaceID, "", false, "connection unavailable", checkedAt, revision) + return + } + username, err := client.GetAuthenticatedUser(ctx) + if err != nil || strings.TrimSpace(username) == "" { + _ = s.updateConfigHealth(ctx, store, workspaceID, "", false, "authentication failed", checkedAt, revision) + return + } + if err := s.updateConfigHealth(ctx, store, workspaceID, username, true, "", checkedAt, revision); err != nil { + s.logger.Warn("gitlab health: persist result", zap.String("workspace_id", workspaceID), zap.Error(err)) + } +} + +func (s *Service) clientForWorkspaceRevision(ctx context.Context, store *Store, workspaceID string) (Client, int64, error) { + s.configMutationMu.Lock() + s.mu.RLock() + secrets := s.workspaceSecrets + s.mu.RUnlock() + snapshot, err := snapshotWorkspaceConnection(ctx, store, secrets, workspaceID) + s.configMutationMu.Unlock() + if err != nil { + return nil, 0, err + } + if snapshot.config == nil { + return nil, 0, ErrNotConfigured + } + token, err := s.tokenForWorkspaceSnapshot(snapshot) + if err != nil { + return nil, snapshot.config.Revision, err + } + client, err := s.buildWorkspaceClient(ctx, snapshot.config, token) + return client, snapshot.config.Revision, err +} + +func (s *Service) tokenForWorkspaceSnapshot(snapshot workspaceConnectionSnapshot) (string, error) { + switch snapshot.config.AuthMethod { + case AuthMethodPAT: + if !snapshot.secret.exists || strings.TrimSpace(snapshot.secret.value) == "" { + return "", fmt.Errorf("%w: token required", ErrInvalidConfig) + } + return strings.TrimSpace(snapshot.secret.value), nil + case AuthMethodEnvironment: + if err := s.validateEnvironmentTokenHost(snapshot.config.AuthMethod, snapshot.config.Host); err != nil { + return "", err + } + token := strings.TrimSpace(os.Getenv(secretNameToken)) + if token == "" { + return "", fmt.Errorf("%w: GITLAB_TOKEN is empty", ErrInvalidConfig) + } + return token, nil + default: + return "", nil + } +} + +func (s *Service) updateConfigHealth(ctx context.Context, store *Store, workspaceID, username string, ok bool, errMsg string, checkedAt time.Time, revision int64) error { + if revision == 0 { + return nil + } + s.configMutationMu.Lock() + defer s.configMutationMu.Unlock() + _, err := store.UpdateConfigHealthForRevision(ctx, workspaceID, username, ok, errMsg, checkedAt, revision) + return err +} diff --git a/apps/backend/internal/gitlab/service_config_test.go b/apps/backend/internal/gitlab/service_config_test.go new file mode 100644 index 0000000000..67f53bd6e8 --- /dev/null +++ b/apps/backend/internal/gitlab/service_config_test.go @@ -0,0 +1,627 @@ +package gitlab + +import ( + "context" + "errors" + "net/http/httptest" + "reflect" + "sync" + "testing" + "time" +) + +func newWorkspaceConfigService(t *testing.T, store *Store, secrets WorkspaceSecretStore) *Service { + t.Helper() + svc := NewService(DefaultHost, NewNoopClient(DefaultHost), AuthMethodNone, nil, newTestLogger(t)) + svc.SetStore(store) + svc.SetWorkspaceSecretStore(secrets) + return svc +} + +func TestResolveGitLabExecutionCredentialsByAuthMethod(t *testing.T) { + store := newTestStore(t) + secrets := &configTestSecrets{values: map[string]string{"gitlab_workspace_pat": "pat-token"}} + seedWorkspace(t, store, "pat") + seedWorkspace(t, store, "environment") + seedWorkspace(t, store, "glab") + for _, cfg := range []*GitLabConfig{ + {WorkspaceID: "pat", Host: "https://gitlab.pat.example", AuthMethod: AuthMethodPAT}, + {WorkspaceID: "environment", Host: DefaultHost, AuthMethod: AuthMethodEnvironment}, + {WorkspaceID: "glab", Host: "https://gitlab.glab.example", AuthMethod: AuthMethodGLab}, + } { + if err := store.SaveConfigForWorkspace(context.Background(), cfg.WorkspaceID, cfg); err != nil { + t.Fatalf("save %s config: %v", cfg.WorkspaceID, err) + } + } + secrets.values[SecretKeyForWorkspace("pat")] = "pat-token" + t.Setenv(secretNameToken, "environment-token") + svc := newWorkspaceConfigService(t, store, secrets) + var resolvedHost string + svc.glabTokenFn = func(_ context.Context, host string) (string, error) { + resolvedHost = host + return "glab-token", nil + } + + tests := []struct{ workspace, host, token string }{ + {"pat", "https://gitlab.pat.example", "pat-token"}, + {"environment", DefaultHost, "environment-token"}, + {"glab", "https://gitlab.glab.example", "glab-token"}, + } + for _, test := range tests { + host, token, err := svc.ResolveGitLabExecutionCredentials(context.Background(), test.workspace) + if err != nil { + t.Fatalf("resolve %s: %v", test.workspace, err) + } + if host != test.host || token != test.token { + t.Fatalf("resolve %s = (%q, %q)", test.workspace, host, token) + } + } + if resolvedHost != "gitlab.glab.example" { + t.Fatalf("glab host = %q", resolvedHost) + } + if _, exists := secrets.values[SecretKeyForWorkspace("glab")]; exists { + t.Fatal("glab execution credential was persisted") + } +} + +func TestServiceConfigInvalidCredentialPreservesWorkingConnection(t *testing.T) { + server := httptest.NewServer(userHandler(func(token string) bool { return token == "valid" })) + t.Cleanup(server.Close) + store := newTestStore(t) + seedWorkspace(t, store, "workspace-a") + secrets := &configTestSecrets{values: make(map[string]string)} + svc := newWorkspaceConfigService(t, store, secrets) + ctx := context.Background() + + if _, err := svc.SetConfigForWorkspace(ctx, "workspace-a", &SetConfigRequest{ + Host: server.URL, AuthMethod: AuthMethodPAT, Token: "valid", + }); err != nil { + t.Fatalf("save valid config: %v", err) + } + if _, err := svc.SetConfigForWorkspace(ctx, "workspace-a", &SetConfigRequest{ + Host: server.URL, AuthMethod: AuthMethodPAT, Token: "invalid", + }); err == nil { + t.Fatal("invalid credential unexpectedly saved") + } + cfg, err := svc.GetConfigForWorkspace(ctx, "workspace-a") + if err != nil { + t.Fatalf("get preserved config: %v", err) + } + if cfg == nil || cfg.Host != server.URL { + t.Fatalf("preserved config = %#v", cfg) + } + if got := secrets.values[SecretKeyForWorkspace("workspace-a")]; got != "valid" { + t.Fatalf("preserved secret = %q, want valid", got) + } +} + +func TestServiceCopyConfigCopiesPATWithoutWatchRows(t *testing.T) { + server := httptest.NewServer(userHandler(func(token string) bool { return token == "valid" })) + t.Cleanup(server.Close) + store := newTestStore(t) + seedWorkspace(t, store, "source") + seedWorkspace(t, store, "target") + secrets := &configTestSecrets{values: make(map[string]string)} + svc := newWorkspaceConfigService(t, store, secrets) + ctx := context.Background() + if _, err := svc.SetConfigForWorkspace(ctx, "source", &SetConfigRequest{ + Host: server.URL, AuthMethod: AuthMethodPAT, Token: "valid", + }); err != nil { + t.Fatalf("save source: %v", err) + } + + copied, err := svc.CopyConfigToWorkspace(ctx, "source", "target") + if err != nil { + t.Fatalf("copy config: %v", err) + } + if copied.Host != server.URL || !copied.HasSecret { + t.Fatalf("copied config = %#v", copied) + } + if got := secrets.values[SecretKeyForWorkspace("target")]; got != "valid" { + t.Fatalf("copied secret = %q", got) + } + var watches int + if err := store.ro.Get(&watches, `SELECT COUNT(*) FROM gitlab_review_watches WHERE workspace_id = 'target'`); err != nil { + t.Fatalf("count target watches: %v", err) + } + if watches != 0 { + t.Fatalf("target watches = %d, want 0", watches) + } +} + +func TestServiceConfigSecretSetFailureRestoresWorkingConnection(t *testing.T) { + store, secrets, svc, before := setupWorkingWorkspaceConfig(t, "workspace-a") + secrets.setFailures = 1 + secrets.mutateBeforeSetError = true + + _, err := svc.SetConfigForWorkspace(context.Background(), "workspace-a", &SetConfigRequest{ + Host: "https://replacement.gitlab.example", AuthMethod: AuthMethodPAT, Token: "replacement-token", + }) + if err == nil { + t.Fatal("expected secret set failure") + } + assertWorkingWorkspaceConfig(t, store, secrets, "workspace-a", before, "working-token") +} + +func TestServiceConfigUpsertFailureRestoresWorkingConnection(t *testing.T) { + store, secrets, svc, before := setupWorkingWorkspaceConfig(t, "workspace-a") + installConfigFailureTrigger(t, store, "fail_gitlab_config_upsert", ` + BEFORE UPDATE OF host ON gitlab_configs + BEGIN SELECT RAISE(FAIL, 'injected config upsert failure'); END`) + + _, err := svc.SetConfigForWorkspace(context.Background(), "workspace-a", &SetConfigRequest{ + Host: "https://replacement.gitlab.example", AuthMethod: AuthMethodPAT, Token: "replacement-token", + }) + if err == nil { + t.Fatal("expected config upsert failure") + } + assertWorkingWorkspaceConfig(t, store, secrets, "workspace-a", before, "working-token") +} + +func TestServiceConfigHealthFailureRestoresWorkingConnection(t *testing.T) { + store, secrets, svc, before := setupWorkingWorkspaceConfig(t, "workspace-a") + installConfigFailureTrigger(t, store, "fail_gitlab_config_health", ` + BEFORE UPDATE OF last_ok ON gitlab_configs + BEGIN SELECT RAISE(FAIL, 'injected config health failure'); END`) + + _, err := svc.SetConfigForWorkspace(context.Background(), "workspace-a", &SetConfigRequest{ + Host: "https://replacement.gitlab.example", AuthMethod: AuthMethodPAT, Token: "replacement-token", + }) + if err == nil { + t.Fatal("expected config health failure") + } + assertWorkingWorkspaceConfig(t, store, secrets, "workspace-a", before, "working-token") +} + +func TestServiceCopyFailureRestoresTargetConnection(t *testing.T) { + store, secrets, svc, before := setupWorkingWorkspaceConfig(t, "target") + seedWorkspace(t, store, "source") + if err := store.UpsertConfigForWorkspace(context.Background(), "source", &GitLabConfig{ + Host: "https://source.gitlab.example", AuthMethod: AuthMethodPAT, Username: "source-user", + }); err != nil { + t.Fatalf("seed source config: %v", err) + } + secrets.values[SecretKeyForWorkspace("source")] = "source-token" + installConfigFailureTrigger(t, store, "fail_gitlab_copy_health", ` + BEFORE UPDATE OF last_ok ON gitlab_configs + BEGIN SELECT RAISE(FAIL, 'injected copy health failure'); END`) + + if _, err := svc.CopyConfigToWorkspace(context.Background(), "source", "target"); err == nil { + t.Fatal("expected copy failure") + } + assertWorkingWorkspaceConfig(t, store, secrets, "target", before, "working-token") +} + +func TestServiceDeleteConfigFailureRestoresCredential(t *testing.T) { + store, secrets, svc, before := setupWorkingWorkspaceConfig(t, "workspace-a") + installConfigFailureTrigger(t, store, "fail_gitlab_config_delete", ` + BEFORE DELETE ON gitlab_configs + BEGIN SELECT RAISE(FAIL, 'injected config delete failure'); END`) + + if err := svc.DeleteConfigForWorkspace(context.Background(), "workspace-a"); err == nil { + t.Fatal("expected config delete failure") + } + assertWorkingWorkspaceConfig(t, store, secrets, "workspace-a", before, "working-token") +} + +func TestServiceDeleteSecretFailureReportsErrorAndRestoresConnection(t *testing.T) { + store, secrets, svc, before := setupWorkingWorkspaceConfig(t, "workspace-a") + secrets.deleteFailures = 1 + secrets.mutateBeforeDeleteError = true + + if err := svc.DeleteConfigForWorkspace(context.Background(), "workspace-a"); err == nil { + t.Fatal("expected secret delete failure") + } + assertWorkingWorkspaceConfig(t, store, secrets, "workspace-a", before, "working-token") +} + +func TestServiceCopyConfigSnapshotsSourceMetadataAndPATAtomically(t *testing.T) { + store := newTestStore(t) + seedWorkspace(t, store, "source") + seedWorkspace(t, store, "target") + if err := store.UpsertConfigForWorkspace(context.Background(), "source", &GitLabConfig{ + Host: "https://old.gitlab.example", AuthMethod: AuthMethodPAT, Username: "old-user", + }); err != nil { + t.Fatalf("seed source config: %v", err) + } + secrets := newBarrierConfigSecrets(SecretKeyForWorkspace("source"), "old-token") + svc := newWorkspaceConfigService(t, store, secrets) + svc.workspaceClientFn = func(_ context.Context, cfg *GitLabConfig, _ string) (Client, error) { + client := NewMockClient(cfg.Host) + client.SetUser("new-user") + return client, nil + } + + updateDone := make(chan error, 1) + go func() { + _, err := svc.SetConfigForWorkspace(context.Background(), "source", &SetConfigRequest{ + Host: "https://new.gitlab.example", AuthMethod: AuthMethodPAT, Token: "new-token", + }) + updateDone <- err + }() + <-secrets.setMutated + + type copyResult struct { + cfg *GitLabConfig + err error + } + copyDone := make(chan copyResult, 1) + go func() { + cfg, err := svc.CopyConfigToWorkspace(context.Background(), "source", "target") + copyDone <- copyResult{cfg: cfg, err: err} + }() + + select { + case <-secrets.sourceRevealed: + // The old implementation can read the newly-mutated token while the + // source metadata is still old. Let it reach target persistence. + case <-time.After(250 * time.Millisecond): + // The fixed implementation is waiting at the config mutation boundary. + } + close(secrets.releaseSet) + if err := <-updateDone; err != nil { + t.Fatalf("update source config: %v", err) + } + copied := <-copyDone + if copied.err != nil { + t.Fatalf("copy config: %v", copied.err) + } + if copied.cfg.Host != "https://new.gitlab.example" { + t.Fatalf("copied host = %q, want new source host", copied.cfg.Host) + } + if got := secrets.value(SecretKeyForWorkspace("target")); got != "new-token" { + t.Fatalf("copied token = %q, want new-token", got) + } +} + +func TestExpectedHostActionWaitsForConfigMutationAndFailsClosed(t *testing.T) { + const workspaceID = "workspace-a" + store := newTestStore(t) + seedWorkspace(t, store, workspaceID) + if err := store.UpsertConfigForWorkspace(t.Context(), workspaceID, &GitLabConfig{ + Host: "https://old.gitlab.example", AuthMethod: AuthMethodPAT, + }); err != nil { + t.Fatalf("seed config: %v", err) + } + secrets := newBarrierConfigSecrets(SecretKeyForWorkspace(workspaceID), "old-token") + svc := newWorkspaceConfigService(t, store, secrets) + svc.workspaceClientFn = func(_ context.Context, cfg *GitLabConfig, _ string) (Client, error) { + return NewMockClient(cfg.Host), nil + } + + mutationDone := make(chan error, 1) + go func() { + mutationDone <- svc.persistWorkspaceConfig(context.Background(), workspaceID, &GitLabConfig{ + Host: "https://new.gitlab.example", AuthMethod: AuthMethodPAT, + }, "new-token") + }() + <-secrets.setMutated + + actionCalls := 0 + actionStarted := make(chan struct{}) + actionDone := make(chan error, 1) + go func() { + close(actionStarted) + actionDone <- svc.RunWithWorkspaceClient( + context.Background(), workspaceID, "https://old.gitlab.example", + func(Client) error { + actionCalls++ + return nil + }, + ) + }() + <-actionStarted + close(secrets.releaseSet) + if err := <-mutationDone; err != nil { + t.Fatalf("persist replacement: %v", err) + } + if err := <-actionDone; !errors.Is(err, ErrWorkspaceHostMismatch) { + t.Fatalf("action error = %v, want host mismatch", err) + } + if actionCalls != 0 { + t.Fatalf("wrong-host upstream calls = %d, want 0", actionCalls) + } +} + +func TestConfigMutationInvalidatesClientBuiltFromPreviousRevision(t *testing.T) { + const workspaceID = "workspace-a" + store := newTestStore(t) + seedWorkspace(t, store, workspaceID) + if err := store.UpsertConfigForWorkspace(t.Context(), workspaceID, &GitLabConfig{ + Host: "https://old.gitlab.example", AuthMethod: AuthMethodPAT, + }); err != nil { + t.Fatalf("seed config: %v", err) + } + secrets := &configTestSecrets{values: map[string]string{ + SecretKeyForWorkspace(workspaceID): "old-token", + }} + svc := newWorkspaceConfigService(t, store, secrets) + buildStarted := make(chan struct{}) + releaseBuild := make(chan struct{}) + svc.workspaceClientFn = func(_ context.Context, cfg *GitLabConfig, _ string) (Client, error) { + if cfg.Host == "https://old.gitlab.example" { + close(buildStarted) + <-releaseBuild + } + return NewMockClient(cfg.Host), nil + } + + oldClientDone := make(chan error, 1) + go func() { + _, err := svc.ClientForWorkspaceHost(context.Background(), workspaceID, "https://old.gitlab.example") + oldClientDone <- err + }() + <-buildStarted + mutationDone := make(chan error, 1) + go func() { + mutationDone <- svc.persistWorkspaceConfig(context.Background(), workspaceID, &GitLabConfig{ + Host: "https://new.gitlab.example", AuthMethod: AuthMethodPAT, + }, "new-token") + }() + if err := <-mutationDone; err != nil { + t.Fatalf("persist new revision: %v", err) + } + close(releaseBuild) + if err := <-oldClientDone; !errors.Is(err, ErrWorkspaceHostMismatch) { + t.Fatalf("stale resolver error = %v, want host mismatch", err) + } + client, err := svc.ClientForWorkspaceHost(t.Context(), workspaceID, "https://new.gitlab.example") + if err != nil { + t.Fatalf("resolve new revision: %v", err) + } + if client.Host() != "https://new.gitlab.example" { + t.Fatalf("cached client host = %q, want new revision", client.Host()) + } +} + +func TestBlockedWorkspaceActionDoesNotBlockOtherWorkspaceOrConfigMutation(t *testing.T) { + store := newTestStore(t) + seedWorkspace(t, store, "workspace-a") + seedWorkspace(t, store, "workspace-b") + for workspaceID, host := range map[string]string{ + "workspace-a": "https://a.gitlab.example", + "workspace-b": "https://b.gitlab.example", + } { + if err := store.UpsertConfigForWorkspace(t.Context(), workspaceID, &GitLabConfig{ + Host: host, AuthMethod: AuthMethodPAT, + }); err != nil { + t.Fatalf("seed %s config: %v", workspaceID, err) + } + } + secrets := newBarrierConfigSecrets("unused", "") + secrets.values[SecretKeyForWorkspace("workspace-a")] = "token-a" + secrets.values[SecretKeyForWorkspace("workspace-b")] = "token-b" + svc := newWorkspaceConfigService(t, store, secrets) + svc.workspaceClientFn = func(_ context.Context, cfg *GitLabConfig, _ string) (Client, error) { + return NewMockClient(cfg.Host), nil + } + + actionAStarted := make(chan struct{}) + releaseActionA := make(chan struct{}) + actionADone := make(chan error, 1) + go func() { + actionADone <- svc.RunWithWorkspaceClient( + context.Background(), "workspace-a", "https://a.gitlab.example", + func(client Client) error { + if client.Host() != "https://a.gitlab.example" { + return errors.New("workspace A resolved wrong host") + } + close(actionAStarted) + <-releaseActionA + return nil + }, + ) + }() + <-actionAStarted + + actionBDone := make(chan error, 1) + go func() { + actionBDone <- svc.RunWithWorkspaceClient( + context.Background(), "workspace-b", "https://b.gitlab.example", + func(Client) error { return nil }, + ) + }() + if err := receiveBarrierResult(t, "workspace B action", actionBDone); err != nil { + t.Fatalf("workspace B action: %v", err) + } + + mutationDone := make(chan error, 1) + go func() { + mutationDone <- svc.persistWorkspaceConfig(context.Background(), "workspace-a", &GitLabConfig{ + Host: "https://a-new.gitlab.example", AuthMethod: AuthMethodPAT, + }, "token-a-new") + }() + if err := receiveBarrierResult(t, "workspace A config mutation", mutationDone); err != nil { + t.Fatalf("workspace A config mutation: %v", err) + } + close(releaseActionA) + if err := receiveBarrierResult(t, "workspace A action", actionADone); err != nil { + t.Fatalf("workspace A action: %v", err) + } +} + +func receiveBarrierResult(t *testing.T, name string, result <-chan error) error { + t.Helper() + select { + case err := <-result: + return err + case <-time.After(5 * time.Second): + t.Fatalf("%s did not cross the barrier", name) + return nil + } +} + +func TestServiceHealthProbeDoesNotOverwriteNewerConfigRevision(t *testing.T) { + store, secrets, svc, _ := setupWorkingWorkspaceConfig(t, "workspace-a") + probeStarted := make(chan struct{}) + releaseProbe := make(chan struct{}) + svc.workspaceClientFn = func(_ context.Context, cfg *GitLabConfig, _ string) (Client, error) { + client := NewMockClient(cfg.Host) + if cfg.Host == "https://working.gitlab.example" { + return &blockingAuthenticatedUserClient{ + Client: client, started: probeStarted, release: releaseProbe, username: "stale-user", + }, nil + } + client.SetUser("current-user") + return client, nil + } + + healthDone := make(chan struct{}) + go func() { + svc.recordWorkspaceAuthHealth(context.Background(), store, "workspace-a") + close(healthDone) + }() + <-probeStarted + + if _, err := svc.SetConfigForWorkspace(context.Background(), "workspace-a", &SetConfigRequest{ + Host: "https://current.gitlab.example", AuthMethod: AuthMethodPAT, Token: "current-token", + }); err != nil { + t.Fatalf("save newer config while probe is blocked: %v", err) + } + close(releaseProbe) + <-healthDone + + cfg, err := store.GetConfigForWorkspace(context.Background(), "workspace-a") + if err != nil { + t.Fatalf("get config after stale probe: %v", err) + } + if cfg.Host != "https://current.gitlab.example" || cfg.Username != "current-user" { + t.Fatalf("newer config overwritten by stale health result: %#v", cfg) + } + if got := secrets.values[SecretKeyForWorkspace("workspace-a")]; got != "current-token" { + t.Fatalf("newer token = %q, want current-token", got) + } +} + +type barrierConfigSecrets struct { + mu sync.Mutex + values map[string]string + blockedKey string + setMutated chan struct{} + releaseSet chan struct{} + sourceRevealed chan struct{} + setOnce sync.Once + revealOnce sync.Once +} + +func newBarrierConfigSecrets(key, value string) *barrierConfigSecrets { + return &barrierConfigSecrets{ + values: map[string]string{key: value}, + blockedKey: key, + setMutated: make(chan struct{}), + releaseSet: make(chan struct{}), + sourceRevealed: make(chan struct{}), + } +} + +func (s *barrierConfigSecrets) Reveal(_ context.Context, id string) (string, error) { + s.mu.Lock() + value := s.values[id] + s.mu.Unlock() + if id == s.blockedKey && value == "new-token" { + s.revealOnce.Do(func() { close(s.sourceRevealed) }) + } + return value, nil +} + +func (s *barrierConfigSecrets) Set(_ context.Context, id, _ string, value string) error { + s.mu.Lock() + s.values[id] = value + s.mu.Unlock() + if id == s.blockedKey && value == "new-token" { + s.setOnce.Do(func() { close(s.setMutated) }) + <-s.releaseSet + } + return nil +} + +func (s *barrierConfigSecrets) Delete(_ context.Context, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.values, id) + return nil +} + +func (s *barrierConfigSecrets) Exists(_ context.Context, id string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + _, ok := s.values[id] + return ok, nil +} + +func (s *barrierConfigSecrets) value(id string) string { + s.mu.Lock() + defer s.mu.Unlock() + return s.values[id] +} + +type blockingAuthenticatedUserClient struct { + Client + started chan struct{} + release chan struct{} + username string +} + +func (c *blockingAuthenticatedUserClient) GetAuthenticatedUser(context.Context) (string, error) { + close(c.started) + <-c.release + return c.username, nil +} + +func setupWorkingWorkspaceConfig(t *testing.T, workspaceID string) (*Store, *configTestSecrets, *Service, *GitLabConfig) { + t.Helper() + store := newTestStore(t) + seedWorkspace(t, store, workspaceID) + secrets := &configTestSecrets{values: map[string]string{ + SecretKeyForWorkspace(workspaceID): "working-token", + }} + checkedAt := time.Date(2026, time.July, 20, 12, 0, 0, 0, time.UTC) + cfg := &GitLabConfig{ + Host: "https://working.gitlab.example", + AuthMethod: AuthMethodPAT, + Username: "working-user", + CreatedAt: checkedAt.Add(-time.Hour), + } + if err := store.UpsertConfigForWorkspace(context.Background(), workspaceID, cfg); err != nil { + t.Fatalf("seed working config: %v", err) + } + updated, err := store.UpdateConfigHealthForRevision( + context.Background(), workspaceID, cfg.Username, true, "", checkedAt, cfg.Revision, + ) + if err != nil { + t.Fatalf("seed working health: %v", err) + } + if !updated { + t.Fatal("seed working health did not match config revision") + } + before, err := store.GetConfigForWorkspace(context.Background(), workspaceID) + if err != nil { + t.Fatalf("read working config: %v", err) + } + svc := newWorkspaceConfigService(t, store, secrets) + svc.workspaceClientFn = func(_ context.Context, cfg *GitLabConfig, _ string) (Client, error) { + return NewMockClient(cfg.Host), nil + } + return store, secrets, svc, before +} + +func installConfigFailureTrigger(t *testing.T, store *Store, name, body string) { + t.Helper() + if _, err := store.db.Exec(`CREATE TRIGGER ` + name + ` ` + body); err != nil { + t.Fatalf("install failure trigger %s: %v", name, err) + } +} + +func assertWorkingWorkspaceConfig(t *testing.T, store *Store, secrets *configTestSecrets, workspaceID string, want *GitLabConfig, wantToken string) { + t.Helper() + got, err := store.GetConfigForWorkspace(context.Background(), workspaceID) + if err != nil { + t.Fatalf("get preserved config: %v", err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("preserved config = %#v, want %#v", got, want) + } + if gotToken := secrets.values[SecretKeyForWorkspace(workspaceID)]; gotToken != wantToken { + t.Fatalf("preserved secret = %q, want %q", gotToken, wantToken) + } +} diff --git a/apps/backend/internal/gitlab/service_events.go b/apps/backend/internal/gitlab/service_events.go index 86e8bf9af0..493703909b 100644 --- a/apps/backend/internal/gitlab/service_events.go +++ b/apps/backend/internal/gitlab/service_events.go @@ -41,12 +41,16 @@ func (s *Service) publishNewReviewMREvent(ctx context.Context, watch *ReviewWatc } ev := &NewReviewMREvent{ ReviewWatchID: watch.ID, + WatchGeneration: watch.Generation, WorkspaceID: watch.WorkspaceID, WorkflowID: watch.WorkflowID, WorkflowStepID: watch.WorkflowStepID, AgentProfileID: watch.AgentProfileID, ExecutorProfileID: watch.ExecutorProfileID, Prompt: watch.Prompt, + RepositoryID: watch.RepositoryID, + BaseBranch: watch.BaseBranch, + MaxInflightTasks: watch.MaxInflightTasks, MR: mr, } if err := eb.Publish(ctx, events.GitLabNewReviewMR, bus.NewEvent(events.GitLabNewReviewMR, eventSource, ev)); err != nil { @@ -64,12 +68,16 @@ func (s *Service) publishNewIssueEvent(ctx context.Context, watch *IssueWatch, i } ev := &NewIssueEvent{ IssueWatchID: watch.ID, + WatchGeneration: watch.Generation, WorkspaceID: watch.WorkspaceID, WorkflowID: watch.WorkflowID, WorkflowStepID: watch.WorkflowStepID, AgentProfileID: watch.AgentProfileID, ExecutorProfileID: watch.ExecutorProfileID, Prompt: watch.Prompt, + RepositoryID: watch.RepositoryID, + BaseBranch: watch.BaseBranch, + MaxInflightTasks: watch.MaxInflightTasks, Issue: issue, } if err := eb.Publish(ctx, events.GitLabNewIssue, bus.NewEvent(events.GitLabNewIssue, eventSource, ev)); err != nil { diff --git a/apps/backend/internal/gitlab/service_issue_query_test.go b/apps/backend/internal/gitlab/service_issue_query_test.go new file mode 100644 index 0000000000..fb5282bfab --- /dev/null +++ b/apps/backend/internal/gitlab/service_issue_query_test.go @@ -0,0 +1,67 @@ +package gitlab + +import ( + "context" + "strings" + "testing" +) + +type captureIssueQueryClient struct { + *MockClient + filter string + customQuery string + listCalls int +} + +func (c *captureIssueQueryClient) ListIssues(_ context.Context, filter, customQuery string) ([]*Issue, error) { + c.filter = filter + c.customQuery = customQuery + c.listCalls++ + return nil, nil +} + +func TestFetchIssuesRejectsEmptyAuthenticatedUsernameBeforeListing(t *testing.T) { + client := &captureIssueQueryClient{MockClient: NewMockClient(DefaultHost)} + client.SetUser("") + svc := NewService(DefaultHost, client, "mock", nil, newTestLogger(t)) + svc.workspaceClients["ws-1"] = client + + issues, err := svc.fetchIssues(context.Background(), &IssueWatch{WorkspaceID: "ws-1"}) + if err == nil || !strings.Contains(err.Error(), "username") { + t.Fatalf("err = %v, want missing username error", err) + } + if len(issues) != 0 || client.listCalls != 0 { + t.Fatalf("issues=%v ListIssues calls=%d, want none", issues, client.listCalls) + } +} + +func TestFetchIssuesDefaultsToAuthenticatedAssignee(t *testing.T) { + client := &captureIssueQueryClient{MockClient: NewMockClient(DefaultHost)} + client.SetUser("issue owner") + svc := NewService(DefaultHost, client, "mock", nil, newTestLogger(t)) + svc.workspaceClients["ws-1"] = client + + if _, err := svc.fetchIssues(context.Background(), &IssueWatch{WorkspaceID: "ws-1"}); err != nil { + t.Fatalf("fetch issues: %v", err) + } + if !strings.Contains(client.filter, "assignee_username=issue+owner") { + t.Fatalf("filter = %q, want authenticated assignee", client.filter) + } + if client.customQuery != "" { + t.Fatalf("custom query = %q, want empty", client.customQuery) + } +} + +func TestFetchIssuesUsesExplicitCustomQueryWithoutImplicitAssignee(t *testing.T) { + client := &captureIssueQueryClient{MockClient: NewMockClient(DefaultHost)} + svc := NewService(DefaultHost, client, "mock", nil, newTestLogger(t)) + svc.workspaceClients["ws-1"] = client + custom := "state=opened&assignee_username=someone-else" + + if _, err := svc.fetchIssues(context.Background(), &IssueWatch{WorkspaceID: "ws-1", CustomQuery: custom}); err != nil { + t.Fatalf("fetch issues: %v", err) + } + if client.filter != "" || client.customQuery != custom { + t.Fatalf("filter=%q custom=%q", client.filter, client.customQuery) + } +} diff --git a/apps/backend/internal/gitlab/service_issue_watches.go b/apps/backend/internal/gitlab/service_issue_watches.go index 60171cfab5..1ba2d4183b 100644 --- a/apps/backend/internal/gitlab/service_issue_watches.go +++ b/apps/backend/internal/gitlab/service_issue_watches.go @@ -18,6 +18,13 @@ func (s *Service) CreateIssueWatch(ctx context.Context, req *CreateIssueWatchReq if !IsValidCleanupPolicy(req.CleanupPolicy) { return nil, fmt.Errorf("invalid cleanup_policy: %q", req.CleanupPolicy) } + if err := s.validateWatchDependencies(ctx, req.WorkspaceID, req.WorkflowID, req.WorkflowStepID, req.AgentProfileID, req.ExecutorProfileID); err != nil { + return nil, err + } + repositoryID, baseBranch, err := s.resolveWatchRepository(ctx, req.WorkspaceID, req.RepositoryID, req.BaseBranch) + if err != nil { + return nil, err + } iw := &IssueWatch{ WorkspaceID: req.WorkspaceID, WorkflowID: req.WorkflowID, @@ -26,11 +33,17 @@ func (s *Service) CreateIssueWatch(ctx context.Context, req *CreateIssueWatchReq AgentProfileID: req.AgentProfileID, ExecutorProfileID: req.ExecutorProfileID, Prompt: req.Prompt, + RepositoryID: repositoryID, + BaseBranch: baseBranch, Labels: req.Labels, CustomQuery: req.CustomQuery, Enabled: true, PollIntervalSeconds: clampPollInterval(req.PollIntervalSeconds), CleanupPolicy: NormalizeCleanupPolicy(req.CleanupPolicy), + MaxInflightTasks: req.MaxInflightTasks, + } + if err := validateWatchMaxInflight(iw.MaxInflightTasks); err != nil { + return nil, err } store := s.requireStore() if store == nil { @@ -64,6 +77,14 @@ func (s *Service) GetIssueWatch(ctx context.Context, id string) (*IssueWatch, er return store.GetIssueWatch(ctx, id) } +func (s *Service) GetIssueWatchIncludingDeleting(ctx context.Context, id string) (*IssueWatch, error) { + store := s.requireStore() + if store == nil { + return nil, errStoreUnavailable + } + return store.GetIssueWatchIncludingDeleting(ctx, id) +} + // ListIssueWatches lists issue watches in a workspace. func (s *Service) ListIssueWatches(ctx context.Context, workspaceID string) ([]*IssueWatch, error) { store := s.requireStore() @@ -99,9 +120,22 @@ func (s *Service) UpdateIssueWatch(ctx context.Context, id string, req *UpdateIs return fmt.Errorf("%w: issue watch %s", ErrWatchNotFound, id) } applyIssueWatchPatch(iw, req) + if err := s.validateWatchDependencies(ctx, iw.WorkspaceID, iw.WorkflowID, iw.WorkflowStepID, iw.AgentProfileID, iw.ExecutorProfileID); err != nil { + return err + } + if req.RepositoryID != nil || req.BaseBranch != nil { + repositoryID, baseBranch, err := s.resolveWatchRepository(ctx, iw.WorkspaceID, iw.RepositoryID, iw.BaseBranch) + if err != nil { + return err + } + iw.RepositoryID, iw.BaseBranch = repositoryID, baseBranch + } if req.CleanupPolicy != nil && !IsValidCleanupPolicy(*req.CleanupPolicy) { return fmt.Errorf("invalid cleanup_policy: %q", *req.CleanupPolicy) } + if err := validateWatchMaxInflight(iw.MaxInflightTasks); err != nil { + return err + } return store.UpdateIssueWatch(ctx, iw) } @@ -125,6 +159,15 @@ func applyIssueWatchPatch(iw *IssueWatch, req *UpdateIssueWatchRequest) { if req.Prompt != nil { iw.Prompt = *req.Prompt } + if req.RepositoryID != nil { + iw.RepositoryID = *req.RepositoryID + if iw.RepositoryID == "" { + iw.BaseBranch = "" + } + } + if req.BaseBranch != nil && iw.RepositoryID != "" { + iw.BaseBranch = *req.BaseBranch + } if req.Labels != nil { iw.Labels = *req.Labels } @@ -133,6 +176,10 @@ func applyIssueWatchPatch(iw *IssueWatch, req *UpdateIssueWatchRequest) { } if req.Enabled != nil { iw.Enabled = *req.Enabled + if iw.Enabled { + iw.LastError = "" + iw.LastErrorAt = nil + } } if req.PollIntervalSeconds != nil { iw.PollIntervalSeconds = clampPollInterval(*req.PollIntervalSeconds) @@ -140,6 +187,13 @@ func applyIssueWatchPatch(iw *IssueWatch, req *UpdateIssueWatchRequest) { if req.CleanupPolicy != nil { iw.CleanupPolicy = NormalizeCleanupPolicy(*req.CleanupPolicy) } + if req.MaxInflightTasks != nil { + if *req.MaxInflightTasks == 0 { + iw.MaxInflightTasks = nil + } else { + iw.MaxInflightTasks = req.MaxInflightTasks + } + } } // DeleteIssueWatch removes an issue watch and best-effort reaps tasks. @@ -148,33 +202,24 @@ func (s *Service) DeleteIssueWatch(ctx context.Context, id string) error { if store == nil { return errStoreUnavailable } + if err := ctx.Err(); err != nil { + return err + } + deleteCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), watchDeleteTimeout) + defer cancel() s.mu.RLock() deleter := s.taskDeleter + cascadeDeleter := s.cascadeTaskDeleter s.mu.RUnlock() - if deleter != nil { - tasks, err := store.ListIssueWatchTasksByWatch(ctx, id) - if err != nil { - s.logger.Warn("failed to list issue tasks for pre-delete sweep", - zap.String("watch_id", id), zap.Error(err)) - } else { - s.sweepIssueWatchTasksOnDelete(ctx, id, tasks, deleter) - } + invalidation, err := store.BeginIssueWatchDelete(deleteCtx, id) + if err != nil { + return err } - return store.DeleteIssueWatch(ctx, id) -} - -func (s *Service) sweepIssueWatchTasksOnDelete(ctx context.Context, watchID string, tasks []*IssueWatchTask, deleter TaskDeleter) { - for _, t := range tasks { - if t.TaskID == "" { - continue - } - if err := deleter.DeleteTask(ctx, t.TaskID); err != nil { - s.logger.Warn("failed to delete issue task during watch cleanup", - zap.String("watch_id", watchID), - zap.String("task_id", t.TaskID), - zap.Error(err)) - } + if invalidation.Missing { + return nil } + s.cleanupInvalidatedWatchTasks(deleteCtx, id, invalidation.TaskIDs, cascadeDeleter, deleter) + return store.DeleteIssueWatch(deleteCtx, id) } // CheckIssueWatch polls a single watch and returns new issues. @@ -187,10 +232,6 @@ func (s *Service) CheckIssueWatch(ctx context.Context, watch *IssueWatch) ([]*Is if !watch.Enabled { return nil, nil } - client := s.Client() - if client == nil { - return nil, ErrNoClient - } store := s.requireStore() if store == nil { return nil, errStoreUnavailable @@ -218,11 +259,17 @@ func (s *Service) CheckIssueWatch(ctx context.Context, watch *IssueWatch) ([]*Is } func (s *Service) fetchIssues(ctx context.Context, watch *IssueWatch) ([]*Issue, error) { - client := s.Client() + client, err := s.ClientForWorkspace(ctx, watch.WorkspaceID) + if err != nil { + return nil, err + } username, err := client.GetAuthenticatedUser(ctx) if err != nil { return nil, fmt.Errorf("resolve gitlab username: %w", err) } + if username == "" { + return nil, fmt.Errorf("no authenticated gitlab username") + } // When a custom_query is set, labels need to be folded into it (the // client's buildIssueSearchQuery returns customQuery verbatim and // ignores the auxiliary `filter` arg). Otherwise build a default @@ -303,3 +350,28 @@ func (s *Service) TriggerIssueWatchAll(ctx context.Context) (int, error) { } return total, nil } + +// TriggerIssueWatchAllForWorkspace runs only watches owned by workspaceID. +func (s *Service) TriggerIssueWatchAllForWorkspace(ctx context.Context, workspaceID string) (int, error) { + store := s.requireStore() + if store == nil { + return 0, errStoreUnavailable + } + watches, err := store.ListEnabledIssueWatchesForWorkspace(ctx, workspaceID) + if err != nil { + return 0, err + } + total := 0 + for _, watch := range watches { + found, checkErr := s.CheckIssueWatch(ctx, watch) + if checkErr != nil { + s.logger.Warn("trigger workspace issue watches", zap.String("watch_id", watch.ID), zap.Error(checkErr)) + continue + } + for _, issue := range found { + s.publishNewIssueEvent(ctx, watch, issue) + } + total += len(found) + } + return total, nil +} diff --git a/apps/backend/internal/gitlab/service_merge.go b/apps/backend/internal/gitlab/service_merge.go index fc1f729ab8..38a43febc4 100644 --- a/apps/backend/internal/gitlab/service_merge.go +++ b/apps/backend/internal/gitlab/service_merge.go @@ -15,6 +15,24 @@ func (s *Service) MergeMR(ctx context.Context, projectPath string, iid int, meth if client == nil { return nil, ErrNoClient } + return s.mergeMRWithClient(ctx, client, projectPath, iid, method, squashCommitMessage) +} + +func (s *Service) MergeMRForWorkspace(ctx context.Context, workspaceID, projectPath string, iid int, method, squashCommitMessage string) (*MR, error) { + return s.MergeMRForWorkspaceHost(ctx, workspaceID, "", projectPath, iid, method, squashCommitMessage) +} + +func (s *Service) MergeMRForWorkspaceHost(ctx context.Context, workspaceID, expectedHost, projectPath string, iid int, method, squashCommitMessage string) (*MR, error) { + var mr *MR + err := s.RunWithWorkspaceClient(ctx, workspaceID, expectedHost, func(client Client) error { + var actionErr error + mr, actionErr = s.mergeMRWithClient(ctx, client, projectPath, iid, method, squashCommitMessage) + return actionErr + }) + return mr, err +} + +func (s *Service) mergeMRWithClient(ctx context.Context, client Client, projectPath string, iid int, method, squashCommitMessage string) (*MR, error) { methods, err := client.GetProjectMergeMethods(ctx, projectPath) if err != nil { return nil, fmt.Errorf("get project merge methods: %w", err) diff --git a/apps/backend/internal/gitlab/service_reservations.go b/apps/backend/internal/gitlab/service_reservations.go index 1b7c6be13f..dbdb930854 100644 --- a/apps/backend/internal/gitlab/service_reservations.go +++ b/apps/backend/internal/gitlab/service_reservations.go @@ -1,59 +1,81 @@ package gitlab -import "context" +import ( + "context" + "errors" + "fmt" +) // ReserveReviewMRTask atomically claims an MR for task creation. Returns // (true, nil) when this caller wins the race, (false, nil) when another // caller has already reserved (or task created). -func (s *Service) ReserveReviewMRTask(ctx context.Context, watchID, projectPath string, iid int, mrURL string) (bool, error) { +func (s *Service) ReserveReviewMRTask(ctx context.Context, watchID string, generation int64, projectPath string, iid int, mrURL string) (bool, error) { store := s.requireStore() if store == nil { return false, errStoreUnavailable } - return store.ReserveReviewMRTask(ctx, watchID, projectPath, iid, mrURL) + return store.ReserveReviewMRTaskForGeneration(ctx, watchID, generation, projectPath, iid, mrURL) } // AssignReviewMRTaskID stamps the claim with the freshly-created task id. -func (s *Service) AssignReviewMRTaskID(ctx context.Context, watchID, projectPath string, iid int, taskID string) error { +func (s *Service) AssignReviewMRTaskID(ctx context.Context, watchID string, generation int64, projectPath string, iid int, taskID string) error { store := s.requireStore() if store == nil { return errStoreUnavailable } - return store.AssignReviewMRTaskID(ctx, watchID, projectPath, iid, taskID) + err := store.AssignReviewMRTaskIDForGeneration(ctx, watchID, generation, projectPath, iid, taskID) + return s.cleanupTaskAfterOwnershipLoss(ctx, taskID, err) } // ReleaseReviewMRTask undoes a reservation (used after task-create failure). -func (s *Service) ReleaseReviewMRTask(ctx context.Context, watchID, projectPath string, iid int) error { +func (s *Service) ReleaseReviewMRTask(ctx context.Context, watchID string, generation int64, projectPath string, iid int) error { store := s.requireStore() if store == nil { return errStoreUnavailable } - return store.ReleaseReviewMRTask(ctx, watchID, projectPath, iid) + return store.ReleaseReviewMRTaskForGeneration(ctx, watchID, generation, projectPath, iid) } // ReserveIssueWatchTask atomically claims an issue for task creation. -func (s *Service) ReserveIssueWatchTask(ctx context.Context, watchID, projectPath string, iid int, issueURL string) (bool, error) { +func (s *Service) ReserveIssueWatchTask(ctx context.Context, watchID string, generation int64, projectPath string, iid int, issueURL string) (bool, error) { store := s.requireStore() if store == nil { return false, errStoreUnavailable } - return store.ReserveIssueWatchTask(ctx, watchID, projectPath, iid, issueURL) + return store.ReserveIssueWatchTaskForGeneration(ctx, watchID, generation, projectPath, iid, issueURL) } // AssignIssueWatchTaskID stamps the claim with the freshly-created task id. -func (s *Service) AssignIssueWatchTaskID(ctx context.Context, watchID, projectPath string, iid int, taskID string) error { +func (s *Service) AssignIssueWatchTaskID(ctx context.Context, watchID string, generation int64, projectPath string, iid int, taskID string) error { store := s.requireStore() if store == nil { return errStoreUnavailable } - return store.AssignIssueWatchTaskID(ctx, watchID, projectPath, iid, taskID) + err := store.AssignIssueWatchTaskIDForGeneration(ctx, watchID, generation, projectPath, iid, taskID) + return s.cleanupTaskAfterOwnershipLoss(ctx, taskID, err) } // ReleaseIssueWatchTask undoes a reservation. -func (s *Service) ReleaseIssueWatchTask(ctx context.Context, watchID, projectPath string, iid int) error { +func (s *Service) ReleaseIssueWatchTask(ctx context.Context, watchID string, generation int64, projectPath string, iid int) error { store := s.requireStore() if store == nil { return errStoreUnavailable } - return store.ReleaseIssueWatchTask(ctx, watchID, projectPath, iid) + return store.ReleaseIssueWatchTaskForGeneration(ctx, watchID, generation, projectPath, iid) +} + +func (s *Service) cleanupTaskAfterOwnershipLoss(ctx context.Context, taskID string, attachErr error) error { + if !errors.Is(attachErr, ErrWatchOwnershipLost) { + return attachErr + } + s.mu.RLock() + deleter := s.taskDeleter + s.mu.RUnlock() + if deleter == nil { + return fmt.Errorf("%w: task deleter not configured", attachErr) + } + if err := deleter.DeleteTask(ctx, taskID); err != nil { + return fmt.Errorf("%w: delete unowned task: %v", attachErr, err) + } + return attachErr } diff --git a/apps/backend/internal/gitlab/service_review_query_test.go b/apps/backend/internal/gitlab/service_review_query_test.go new file mode 100644 index 0000000000..718a7b22ea --- /dev/null +++ b/apps/backend/internal/gitlab/service_review_query_test.go @@ -0,0 +1,50 @@ +package gitlab + +import ( + "context" + "strings" + "testing" +) + +type captureReviewQueryClient struct { + *MockClient + filter string + customQuery string +} + +func (c *captureReviewQueryClient) SearchMRs(_ context.Context, filter, customQuery string) ([]*MR, error) { + c.filter = filter + c.customQuery = customQuery + return nil, nil +} + +func TestFetchReviewMRsDefaultsToAuthenticatedReviewer(t *testing.T) { + client := &captureReviewQueryClient{MockClient: NewMockClient(DefaultHost)} + client.SetUser("reviewer name") + svc := NewService(DefaultHost, client, "mock", nil, newTestLogger(t)) + svc.workspaceClients["ws-1"] = client + + if _, err := svc.fetchReviewMRs(context.Background(), &ReviewWatch{WorkspaceID: "ws-1"}); err != nil { + t.Fatalf("fetch review MRs: %v", err) + } + if !strings.Contains(client.filter, "reviewer_username=reviewer+name") { + t.Fatalf("filter = %q, want authenticated reviewer", client.filter) + } + if client.customQuery != "" { + t.Fatalf("custom query = %q, want empty", client.customQuery) + } +} + +func TestFetchReviewMRsUsesExplicitCustomQueryWithoutImplicitReviewer(t *testing.T) { + client := &captureReviewQueryClient{MockClient: NewMockClient(DefaultHost)} + svc := NewService(DefaultHost, client, "mock", nil, newTestLogger(t)) + svc.workspaceClients["ws-1"] = client + custom := "state=opened&reviewer_username=someone-else" + + if _, err := svc.fetchReviewMRs(context.Background(), &ReviewWatch{WorkspaceID: "ws-1", CustomQuery: custom}); err != nil { + t.Fatalf("fetch review MRs: %v", err) + } + if client.filter != "" || client.customQuery != custom { + t.Fatalf("filter=%q custom=%q", client.filter, client.customQuery) + } +} diff --git a/apps/backend/internal/gitlab/service_search.go b/apps/backend/internal/gitlab/service_search.go index 7fddd61f43..06e8d4f805 100644 --- a/apps/backend/internal/gitlab/service_search.go +++ b/apps/backend/internal/gitlab/service_search.go @@ -48,6 +48,18 @@ func (s *Service) SearchUserIssuesPaged(ctx context.Context, filter, customQuery // log gives ops something to triage when counts look wrong. func (s *Service) GetStats(ctx context.Context) (*Stats, error) { client := s.Client() + return s.getStatsWithClient(ctx, client) +} + +func (s *Service) GetStatsForWorkspace(ctx context.Context, workspaceID string) (*Stats, error) { + client, err := s.ClientForWorkspace(ctx, workspaceID) + if err != nil { + return nil, err + } + return s.getStatsWithClient(ctx, client) +} + +func (s *Service) getStatsWithClient(ctx context.Context, client Client) (*Stats, error) { if client == nil { return &Stats{}, nil } diff --git a/apps/backend/internal/gitlab/service_task_mr_link.go b/apps/backend/internal/gitlab/service_task_mr_link.go new file mode 100644 index 0000000000..5a3127186b --- /dev/null +++ b/apps/backend/internal/gitlab/service_task_mr_link.go @@ -0,0 +1,165 @@ +package gitlab + +import ( + "context" + "errors" + "fmt" + "net/url" + "strconv" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" +) + +var ( + // ErrInvalidMRURL marks malformed MR URLs and URLs outside the configured host. + ErrInvalidMRURL = errors.New("invalid GitLab merge request URL") + // ErrTaskMRNotFound covers absent and cross-workspace task-MR resources. + ErrTaskMRNotFound = errors.New("task merge request association not found") + // ErrTaskMRRepositoryRequired prevents ambiguous links on multi-repository tasks. + ErrTaskMRRepositoryRequired = errors.New("repository_id is required for multi-repository tasks") + // ErrTaskMRRepositoryMismatch prevents an MR from being linked to a repository + // whose durable provider origin and project identity do not match the MR. + ErrTaskMRRepositoryMismatch = errors.New("repository does not match GitLab merge request") +) + +func parseMRURLForHost(rawURL, configuredHost string) (string, int, error) { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil { + return "", 0, ErrInvalidMRURL + } + origin, err := normalizeHostOrigin(parsed.Scheme + "://" + parsed.Host) + if err != nil { + return "", 0, ErrInvalidMRURL + } + expectedOrigin, err := normalizeHostOrigin(configuredHost) + if err != nil || !strings.EqualFold(origin, expectedOrigin) { + return "", 0, fmt.Errorf("%w: host does not match workspace connection", ErrInvalidMRURL) + } + const marker = "/-/merge_requests/" + path := strings.TrimRight(parsed.Path, "/") + markerIndex := strings.LastIndex(path, marker) + if markerIndex <= 0 { + return "", 0, ErrInvalidMRURL + } + projectPath := strings.Trim(path[:markerIndex], "/") + iidText := path[markerIndex+len(marker):] + if projectPath == "" || iidText == "" || strings.Contains(iidText, "/") { + return "", 0, ErrInvalidMRURL + } + iid, err := strconv.Atoi(iidText) + if err != nil || iid <= 0 { + return "", 0, ErrInvalidMRURL + } + return projectPath, iid, nil +} + +// AssociateExistingMRByURL validates a workspace-owned task/repository pair, +// fetches the configured-host MR, and idempotently persists its association. +func (s *Service) AssociateExistingMRByURL( + ctx context.Context, + workspaceID, taskID, repositoryID, mrURL string, +) (*TaskMR, error) { + store := s.requireStore() + if store == nil { + return nil, errors.New("gitlab store not configured") + } + client, err := s.ClientForWorkspace(ctx, workspaceID) + if err != nil { + return nil, err + } + projectPath, iid, err := parseMRURLForHost(mrURL, client.Host()) + if err != nil { + return nil, err + } + repositoryID, err = store.ResolveTaskMRRepository(ctx, workspaceID, taskID, repositoryID) + if err != nil { + return nil, err + } + if err := store.ValidateTaskMRRepositoryIdentity( + ctx, workspaceID, taskID, repositoryID, client.Host(), projectPath, + ); err != nil { + return nil, err + } + status, err := client.GetMRStatus(ctx, projectPath, iid) + if err != nil { + return nil, fmt.Errorf("fetch merge request: %w", err) + } + if err := validateReturnedMRIdentity(status, client.Host(), projectPath, iid); err != nil { + return nil, ErrTaskMRNotFound + } + association := taskMRFromStatus(taskID, repositoryID, client.Host(), projectPath, status) + if err := store.UpsertTaskMR(ctx, association); err != nil { + return nil, fmt.Errorf("upsert task MR: %w", err) + } + s.publishTaskMRUpdated(ctx, workspaceID, association) + return association, nil +} + +func validateReturnedMRIdentity(status *MRStatus, host, projectPath string, iid int) error { + if status == nil || status.MR == nil { + return ErrTaskMRNotFound + } + returnedProjectPath, returnedIID, err := parseMRURLForHost(status.MR.WebURL, host) + if err != nil || status.MR.IID != iid || returnedIID != iid { + return ErrTaskMRNotFound + } + if !strings.EqualFold(returnedProjectPath, projectPath) { + return ErrTaskMRNotFound + } + if status.MR.ProjectPath != "" && + !strings.EqualFold(strings.Trim(status.MR.ProjectPath, "/"), projectPath) { + return ErrTaskMRNotFound + } + return nil +} + +// UnlinkTaskMR removes one association and its matching refresh watch without +// mutating the task, other associations, or the upstream merge request. +func (s *Service) UnlinkTaskMR(ctx context.Context, workspaceID, associationID string) error { + store := s.requireStore() + if store == nil { + return errors.New("gitlab store not configured") + } + return store.DeleteTaskMRForWorkspace(ctx, workspaceID, associationID) +} + +func taskMRFromStatus(taskID, repositoryID, host, projectPath string, status *MRStatus) *TaskMR { + mr := status.MR + now := time.Now().UTC() + return &TaskMR{ + TaskID: taskID, RepositoryID: repositoryID, Host: host, + ProjectPath: projectPath, MRIID: mr.IID, MRURL: mr.WebURL, MRTitle: mr.Title, + HeadBranch: mr.HeadBranch, BaseBranch: mr.BaseBranch, AuthorUsername: mr.AuthorUsername, + State: mr.State, ApprovalState: status.ApprovalState, PipelineState: status.PipelineState, + MergeStatus: status.MergeStatus, Draft: mr.Draft, ApprovalCount: status.ApprovalCount, + RequiredApprovals: status.RequiredApprovals, PipelineJobsTotal: status.PipelineJobsTotal, + PipelineJobsPass: status.PipelineJobsPassing, CreatedAt: mr.CreatedAt, MergedAt: mr.MergedAt, + ClosedAt: mr.ClosedAt, LastSyncedAt: &now, + } +} + +type taskMRUpdatedEvent struct { + WorkspaceID string `json:"workspace_id"` + *TaskMR +} + +func (s *Service) publishTaskMRUpdated(ctx context.Context, workspaceID string, association *TaskMR) { + s.mu.RLock() + eventBus := s.eventBus + s.mu.RUnlock() + if eventBus == nil { + return + } + event := bus.NewEvent(events.GitLabTaskMRUpdated, eventSource, &taskMRUpdatedEvent{ + WorkspaceID: workspaceID, + TaskMR: association, + }) + if err := eventBus.Publish(ctx, events.GitLabTaskMRUpdated, event); err != nil { + s.logger.Debug("publish GitLab task MR update", zap.Error(err)) + } +} diff --git a/apps/backend/internal/gitlab/service_task_mr_link_test.go b/apps/backend/internal/gitlab/service_task_mr_link_test.go new file mode 100644 index 0000000000..c2b962bf2d --- /dev/null +++ b/apps/backend/internal/gitlab/service_task_mr_link_test.go @@ -0,0 +1,273 @@ +package gitlab + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +func seedTaskMRLinkFixture(t *testing.T, store *Store, workspaceID, taskID, repositoryID string) { + t.Helper() + seedWorkspace(t, store, workspaceID) + seedTask(t, store, taskID, workspaceID) + if _, err := store.db.Exec(`CREATE TABLE IF NOT EXISTS repositories ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + provider TEXT NOT NULL DEFAULT '', + provider_host TEXT NOT NULL DEFAULT '', + provider_owner TEXT NOT NULL DEFAULT '', + provider_name TEXT NOT NULL DEFAULT '' + ); CREATE TABLE IF NOT EXISTS task_repositories ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + repository_id TEXT NOT NULL + )`); err != nil { + t.Fatalf("create repository fixtures: %v", err) + } + if repositoryID == "" { + return + } + if _, err := store.db.Exec(`INSERT INTO repositories (id, workspace_id) VALUES (?, ?)`, repositoryID, workspaceID); err != nil { + t.Fatalf("seed repository: %v", err) + } + if _, err := store.db.Exec( + `INSERT INTO task_repositories (id, task_id, repository_id) VALUES (?, ?, ?)`, + "task-repo-"+taskID+"-"+repositoryID, taskID, repositoryID, + ); err != nil { + t.Fatalf("seed task repository: %v", err) + } +} + +func setTaskMRRepositoryIdentity( + t *testing.T, store *Store, repositoryID, host, projectPath string, +) { + t.Helper() + projectPath = strings.Trim(projectPath, "/") + lastSlash := strings.LastIndex(projectPath, "/") + if lastSlash <= 0 || lastSlash == len(projectPath)-1 { + t.Fatalf("project path %q has no namespace", projectPath) + } + owner, name := projectPath[:lastSlash], projectPath[lastSlash+1:] + if _, err := store.db.Exec(`UPDATE repositories + SET provider = 'gitlab', provider_host = ?, provider_owner = ?, provider_name = ? + WHERE id = ?`, host, owner, name, repositoryID); err != nil { + t.Fatalf("set repository identity: %v", err) + } +} + +func newTaskMRLinkService(t *testing.T, host string) (*Service, *Store, *MockClient) { + t.Helper() + store := newTestStore(t) + client := NewMockClient(host) + svc := NewService(host, client, AuthMethodPAT, nil, newTestLogger(t)) + svc.SetStore(store) + svc.workspaceClients["ws-1"] = client + return svc, store, client +} + +func TestAssociateExistingMRByURLCreatesIdempotentWorkspaceScopedLink(t *testing.T) { + const host = "https://gitlab.acme.test" + svc, store, client := newTaskMRLinkService(t, host) + seedTaskMRLinkFixture(t, store, "ws-1", "task-1", "repo-1") + setTaskMRRepositoryIdentity(t, store, "repo-1", host, "group/subgroup/project") + client.SeedMR("group/subgroup/project", &MR{ + IID: 17, Title: "MR title", WebURL: host + "/group/subgroup/project/-/merge_requests/17", + State: "opened", HeadBranch: "feature", BaseBranch: "main", CreatedAt: time.Now().UTC(), + }) + + first, err := svc.AssociateExistingMRByURL( + context.Background(), "ws-1", "task-1", "repo-1", + host+"/group/subgroup/project/-/merge_requests/17?view=parallel#note_1", + ) + if err != nil { + t.Fatalf("AssociateExistingMRByURL: %v", err) + } + second, err := svc.AssociateExistingMRByURL( + context.Background(), "ws-1", "task-1", "repo-1", + host+"/group/subgroup/project/-/merge_requests/17", + ) + if err != nil { + t.Fatalf("second AssociateExistingMRByURL: %v", err) + } + if first.ID == "" || second.ID != first.ID { + t.Fatalf("association IDs = %q, %q; want one stable ID", first.ID, second.ID) + } + if first.ProjectPath != "group/subgroup/project" || first.MRIID != 17 { + t.Fatalf("parsed MR identity = %s!%d", first.ProjectPath, first.MRIID) + } + rows, err := store.ListTaskMRsByTask(context.Background(), "task-1") + if err != nil || len(rows) != 1 { + t.Fatalf("stored rows = %d, err = %v; want one", len(rows), err) + } +} + +func TestAssociateExistingMRByURLRejectsWrongHostAndCrossWorkspaceResources(t *testing.T) { + const host = "http://gitlab.internal.test" + svc, store, client := newTaskMRLinkService(t, host) + seedTaskMRLinkFixture(t, store, "ws-1", "task-1", "repo-1") + setTaskMRRepositoryIdentity(t, store, "repo-1", host, "acme/api") + seedTaskMRLinkFixture(t, store, "ws-2", "task-2", "repo-2") + setTaskMRRepositoryIdentity(t, store, "repo-2", host, "acme/api") + client.SeedMR("acme/api", &MR{ + IID: 4, Title: "MR", WebURL: host + "/acme/api/-/merge_requests/4", + State: "opened", CreatedAt: time.Now().UTC(), + }) + + _, err := svc.AssociateExistingMRByURL( + context.Background(), "ws-1", "task-1", "repo-1", + "https://gitlab.com/acme/api/-/merge_requests/4", + ) + if !errors.Is(err, ErrInvalidMRURL) { + t.Fatalf("wrong-host error = %v, want ErrInvalidMRURL", err) + } + + _, err = svc.AssociateExistingMRByURL( + context.Background(), "ws-1", "task-2", "repo-2", + host+"/acme/api/-/merge_requests/4", + ) + if !errors.Is(err, ErrTaskMRNotFound) { + t.Fatalf("cross-workspace error = %v, want ErrTaskMRNotFound", err) + } + + _, err = svc.AssociateExistingMRByURL( + context.Background(), "ws-1", "task-1", "repo-2", + host+"/acme/api/-/merge_requests/4", + ) + if !errors.Is(err, ErrTaskMRNotFound) { + t.Fatalf("cross-workspace repository error = %v, want ErrTaskMRNotFound", err) + } + rows, listErr := store.ListTaskMRsByTask(context.Background(), "task-1") + if listErr != nil || len(rows) != 0 { + t.Fatalf("rejected links persisted rows = %d, err = %v", len(rows), listErr) + } +} + +func TestAssociateExistingMRByURLRejectsRepositoryIdentityMismatch(t *testing.T) { + const host = "http://gitlab.internal.test:8080" + tests := []struct { + name string + repositoryHost string + repositoryPath string + }{ + {name: "same hostname with HTTPS origin", repositoryHost: "https://gitlab.internal.test:8080", repositoryPath: "group/subgroup/project"}, + {name: "different GitLab host", repositoryHost: "http://other.internal.test:8080", repositoryPath: "group/subgroup/project"}, + {name: "different subgroup project", repositoryHost: host, repositoryPath: "group/other/project"}, + {name: "unknown legacy host", repositoryHost: "", repositoryPath: "group/subgroup/project"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc, store, client := newTaskMRLinkService(t, host) + seedTaskMRLinkFixture(t, store, "ws-1", "task-1", "repo-1") + setTaskMRRepositoryIdentity(t, store, "repo-1", tt.repositoryHost, tt.repositoryPath) + client.SeedMR("group/subgroup/project", &MR{ + IID: 9, Title: "MR", WebURL: host + "/group/subgroup/project/-/merge_requests/9", + State: "opened", CreatedAt: time.Now().UTC(), + }) + + _, err := svc.AssociateExistingMRByURL( + context.Background(), "ws-1", "task-1", "repo-1", + host+"/group/subgroup/project/-/merge_requests/9", + ) + if !errors.Is(err, ErrTaskMRRepositoryMismatch) { + t.Fatalf("error = %v, want ErrTaskMRRepositoryMismatch", err) + } + }) + } +} + +func TestAssociateExistingMRByURLAcceptsExactSelfManagedHTTPSRepositoryIdentity(t *testing.T) { + const host = "https://gitlab.internal.test:8443" + svc, store, client := newTaskMRLinkService(t, host) + seedTaskMRLinkFixture(t, store, "ws-1", "task-1", "repo-1") + setTaskMRRepositoryIdentity(t, store, "repo-1", host+"/", "group/subgroup/project") + client.SeedMR("group/subgroup/project", &MR{ + IID: 11, Title: "MR", WebURL: host + "/group/subgroup/project/-/merge_requests/11", + State: "opened", CreatedAt: time.Now().UTC(), + }) + + if _, err := svc.AssociateExistingMRByURL( + context.Background(), "ws-1", "task-1", "repo-1", + host+"/group/subgroup/project/-/merge_requests/11", + ); err != nil { + t.Fatalf("AssociateExistingMRByURL: %v", err) + } +} + +func TestAssociateExistingMRByURLInfersSoleRepositoryAndRejectsMultiRepoAmbiguity(t *testing.T) { + const host = "https://gitlab.acme.test" + svc, store, client := newTaskMRLinkService(t, host) + seedTaskMRLinkFixture(t, store, "ws-1", "task-1", "repo-1") + setTaskMRRepositoryIdentity(t, store, "repo-1", host, "acme/api") + client.SeedMR("acme/api", &MR{ + IID: 8, Title: "MR", WebURL: host + "/acme/api/-/merge_requests/8", + State: "opened", CreatedAt: time.Now().UTC(), + }) + + association, err := svc.AssociateExistingMRByURL( + context.Background(), "ws-1", "task-1", "", host+"/acme/api/-/merge_requests/8", + ) + if err != nil { + t.Fatalf("infer sole repository: %v", err) + } + if association.RepositoryID != "repo-1" { + t.Fatalf("repository_id = %q, want repo-1", association.RepositoryID) + } + + if _, err := store.db.Exec(`INSERT INTO repositories (id, workspace_id) VALUES ('repo-2', 'ws-1'); + INSERT INTO task_repositories (id, task_id, repository_id) VALUES ('task-repo-2', 'task-1', 'repo-2')`); err != nil { + t.Fatalf("seed second task repository: %v", err) + } + setTaskMRRepositoryIdentity(t, store, "repo-2", host, "acme/other") + _, err = svc.AssociateExistingMRByURL( + context.Background(), "ws-1", "task-1", "", host+"/acme/api/-/merge_requests/8", + ) + if !errors.Is(err, ErrTaskMRRepositoryRequired) { + t.Fatalf("ambiguous repository error = %v, want ErrTaskMRRepositoryRequired", err) + } +} + +func TestUnlinkTaskMRRemovesOnlySelectedAssociationAndRefreshWatch(t *testing.T) { + svc, store, client := newTaskMRLinkService(t, DefaultHost) + seedTaskMRLinkFixture(t, store, "ws-1", "task-1", "repo-1") + setTaskMRRepositoryIdentity(t, store, "repo-1", DefaultHost, "acme/api") + for iid := 1; iid <= 2; iid++ { + client.SeedMR("acme/api", &MR{ + IID: iid, Title: "MR", WebURL: DefaultHost + "/acme/api/-/merge_requests/" + string(rune('0'+iid)), + State: "opened", CreatedAt: time.Now().UTC(), + }) + } + selected, err := svc.AssociateExistingMRByURL( + context.Background(), "ws-1", "task-1", "repo-1", DefaultHost+"/acme/api/-/merge_requests/1", + ) + if err != nil { + t.Fatalf("associate selected: %v", err) + } + _, err = svc.AssociateExistingMRByURL( + context.Background(), "ws-1", "task-1", "repo-1", DefaultHost+"/acme/api/-/merge_requests/2", + ) + if err != nil { + t.Fatalf("associate retained: %v", err) + } + for iid := 1; iid <= 2; iid++ { + if err := store.CreateMRWatch(context.Background(), &MRWatch{ + SessionID: "session-" + string(rune('0'+iid)), TaskID: "task-1", RepositoryID: "repo-1", + ProjectPath: "acme/api", MRIID: iid, Branch: "feature", + }); err != nil { + t.Fatalf("create MR watch: %v", err) + } + } + + if err := svc.UnlinkTaskMR(context.Background(), "ws-1", selected.ID); err != nil { + t.Fatalf("UnlinkTaskMR: %v", err) + } + rows, _ := store.ListTaskMRsByTask(context.Background(), "task-1") + watches, _ := store.ListMRWatchesByTask(context.Background(), "task-1") + if len(rows) != 1 || rows[0].MRIID != 2 { + t.Fatalf("remaining associations = %+v, want only !2", rows) + } + if len(watches) != 1 || watches[0].MRIID != 2 { + t.Fatalf("remaining refresh watches = %+v, want only !2", watches) + } +} diff --git a/apps/backend/internal/gitlab/service_watch_dependencies_test.go b/apps/backend/internal/gitlab/service_watch_dependencies_test.go new file mode 100644 index 0000000000..b03541be62 --- /dev/null +++ b/apps/backend/internal/gitlab/service_watch_dependencies_test.go @@ -0,0 +1,204 @@ +package gitlab + +import ( + "context" + "errors" + "fmt" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +type dependencyTestLookup struct{} + +func (dependencyTestLookup) GetRepository(_ context.Context, id string) (string, string, bool) { + switch id { + case "repo-valid": + return "ws-1", "main", true + case "repo-other": + return "ws-2", "main", true + default: + return "", "", false + } +} + +type dependencyTestValidator struct{} + +func (dependencyTestValidator) WorkflowStepBelongs(_ context.Context, workspaceID, workflowID, stepID string) (bool, error) { + return workspaceID == "ws-1" && workflowID == "workflow-valid" && stepID == "step-valid", nil +} +func (dependencyTestValidator) AgentProfileBelongs(_ context.Context, workspaceID, profileID string) (bool, error) { + return workspaceID == "ws-1" && profileID == "agent-valid", nil +} +func (dependencyTestValidator) ExecutorProfileBelongs(_ context.Context, workspaceID, profileID string) (bool, error) { + return workspaceID == "ws-1" && profileID == "executor-valid", nil +} + +func newDependencyTestService(t *testing.T) (*Service, *Store) { + t.Helper() + store := newTestStore(t) + svc := NewService("", nil, "none", nil, newTestLogger(t)) + svc.SetStore(store) + svc.SetRepositoryLookup(dependencyTestLookup{}) + svc.SetWatchDependencyValidator(dependencyTestValidator{}) + return svc, store +} + +func validReviewWatchRequest() *CreateReviewWatchRequest { + return &CreateReviewWatchRequest{ + WorkspaceID: "ws-1", WorkflowID: "workflow-valid", WorkflowStepID: "step-valid", + AgentProfileID: "agent-valid", ExecutorProfileID: "executor-valid", RepositoryID: "repo-valid", + } +} + +func validIssueWatchRequest() *CreateIssueWatchRequest { + return &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", WorkflowID: "workflow-valid", WorkflowStepID: "step-valid", + AgentProfileID: "agent-valid", ExecutorProfileID: "executor-valid", RepositoryID: "repo-valid", + } +} + +func TestCreateGitLabWatchesRejectMissingAndCrossWorkspaceDependencies(t *testing.T) { + tests := []struct { + name string + mutate func(*CreateReviewWatchRequest, *CreateIssueWatchRequest) + }{ + {"cross-workspace workflow", func(r *CreateReviewWatchRequest, i *CreateIssueWatchRequest) { + r.WorkflowID, i.WorkflowID = "workflow-other", "workflow-other" + }}, + {"missing step", func(r *CreateReviewWatchRequest, i *CreateIssueWatchRequest) { + r.WorkflowStepID, i.WorkflowStepID = "step-missing", "step-missing" + }}, + {"missing agent profile", func(r *CreateReviewWatchRequest, i *CreateIssueWatchRequest) { + r.AgentProfileID, i.AgentProfileID = "agent-missing", "agent-missing" + }}, + {"missing executor profile", func(r *CreateReviewWatchRequest, i *CreateIssueWatchRequest) { + r.ExecutorProfileID, i.ExecutorProfileID = "executor-missing", "executor-missing" + }}, + {"cross-workspace repository", func(r *CreateReviewWatchRequest, i *CreateIssueWatchRequest) { + r.RepositoryID, i.RepositoryID = "repo-other", "repo-other" + }}, + {"missing repository", func(r *CreateReviewWatchRequest, i *CreateIssueWatchRequest) { + r.RepositoryID, i.RepositoryID = "repo-missing", "repo-missing" + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + svc, _ := newDependencyTestService(t) + reviewReq, issueReq := validReviewWatchRequest(), validIssueWatchRequest() + test.mutate(reviewReq, issueReq) + if _, err := svc.CreateReviewWatch(context.Background(), reviewReq); !errors.Is(err, ErrInvalidConfig) { + t.Fatalf("review error = %v", err) + } + if _, err := svc.CreateIssueWatch(context.Background(), issueReq); !errors.Is(err, ErrInvalidConfig) { + t.Fatalf("issue error = %v", err) + } + }) + } +} + +func TestEditGitLabWatchesRejectMissingAndCrossWorkspaceDependencies(t *testing.T) { + tests := []struct { + name string + reviewPatch *UpdateReviewWatchRequest + issuePatch *UpdateIssueWatchRequest + }{ + {"cross-workspace workflow", &UpdateReviewWatchRequest{WorkflowID: strptr("workflow-other")}, &UpdateIssueWatchRequest{WorkflowID: strptr("workflow-other")}}, + {"missing step", &UpdateReviewWatchRequest{WorkflowStepID: strptr("step-missing")}, &UpdateIssueWatchRequest{WorkflowStepID: strptr("step-missing")}}, + {"missing agent profile", &UpdateReviewWatchRequest{AgentProfileID: strptr("agent-missing")}, &UpdateIssueWatchRequest{AgentProfileID: strptr("agent-missing")}}, + {"missing executor profile", &UpdateReviewWatchRequest{ExecutorProfileID: strptr("executor-missing")}, &UpdateIssueWatchRequest{ExecutorProfileID: strptr("executor-missing")}}, + {"cross-workspace repository", &UpdateReviewWatchRequest{RepositoryID: strptr("repo-other")}, &UpdateIssueWatchRequest{RepositoryID: strptr("repo-other")}}, + {"missing repository", &UpdateReviewWatchRequest{RepositoryID: strptr("repo-missing")}, &UpdateIssueWatchRequest{RepositoryID: strptr("repo-missing")}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + svc, store := newDependencyTestService(t) + review := validReviewWatchRow() + issue := validIssueWatchRow() + if err := store.CreateReviewWatch(context.Background(), review); err != nil { + t.Fatalf("seed review: %v", err) + } + if err := store.CreateIssueWatch(context.Background(), issue); err != nil { + t.Fatalf("seed issue: %v", err) + } + if err := svc.UpdateReviewWatch(context.Background(), review.ID, test.reviewPatch); !errors.Is(err, ErrInvalidConfig) { + t.Fatalf("review error = %v", err) + } + if err := svc.UpdateIssueWatch(context.Background(), issue.ID, test.issuePatch); !errors.Is(err, ErrInvalidConfig) { + t.Fatalf("issue error = %v", err) + } + }) + } +} + +func TestEditGitLabWatchesZeroMaxInflightClearsExistingCap(t *testing.T) { + svc, store := newDependencyTestService(t) + limit := 3 + clearLimit := 0 + review := validReviewWatchRow() + review.MaxInflightTasks = &limit + issue := validIssueWatchRow() + issue.MaxInflightTasks = &limit + if err := store.CreateReviewWatch(context.Background(), review); err != nil { + t.Fatalf("seed review: %v", err) + } + if err := store.CreateIssueWatch(context.Background(), issue); err != nil { + t.Fatalf("seed issue: %v", err) + } + + if err := svc.UpdateReviewWatch(context.Background(), review.ID, &UpdateReviewWatchRequest{MaxInflightTasks: &clearLimit}); err != nil { + t.Fatalf("clear review max inflight: %v", err) + } + if err := svc.UpdateIssueWatch(context.Background(), issue.ID, &UpdateIssueWatchRequest{MaxInflightTasks: &clearLimit}); err != nil { + t.Fatalf("clear issue max inflight: %v", err) + } + updatedReview, err := svc.GetReviewWatch(context.Background(), review.ID) + if err != nil { + t.Fatalf("get review: %v", err) + } + updatedIssue, err := svc.GetIssueWatch(context.Background(), issue.ID) + if err != nil { + t.Fatalf("get issue: %v", err) + } + if updatedReview.MaxInflightTasks != nil || updatedIssue.MaxInflightTasks != nil { + t.Fatalf("limits not cleared: review=%v issue=%v", updatedReview.MaxInflightTasks, updatedIssue.MaxInflightTasks) + } +} + +func TestCreateGitLabWatchesStillRejectZeroMaxInflight(t *testing.T) { + svc, _ := newDependencyTestService(t) + zero := 0 + review := validReviewWatchRequest() + review.MaxInflightTasks = &zero + issue := validIssueWatchRequest() + issue.MaxInflightTasks = &zero + if _, err := svc.CreateReviewWatch(context.Background(), review); err == nil { + t.Fatal("review create accepted a zero max inflight cap") + } + if _, err := svc.CreateIssueWatch(context.Background(), issue); err == nil { + t.Fatal("issue create accepted a zero max inflight cap") + } +} + +func validReviewWatchRow() *ReviewWatch { + return &ReviewWatch{WorkspaceID: "ws-1", WorkflowID: "workflow-valid", WorkflowStepID: "step-valid", AgentProfileID: "agent-valid", ExecutorProfileID: "executor-valid", RepositoryID: "repo-valid", BaseBranch: "main", Enabled: true} +} +func validIssueWatchRow() *IssueWatch { + return &IssueWatch{WorkspaceID: "ws-1", WorkflowID: "workflow-valid", WorkflowStepID: "step-valid", AgentProfileID: "agent-valid", ExecutorProfileID: "executor-valid", RepositoryID: "repo-valid", BaseBranch: "main", Enabled: true} +} +func strptr(value string) *string { return &value } + +func TestWatchDependencyControllerErrorDoesNotDiscloseResource(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + secretID := "repo-sensitive-123" + if !httpRespondError(ctx, fmt.Errorf("%w: repository %s belongs elsewhere", ErrInvalidConfig, secretID)) { + t.Fatal("error was not handled") + } + if recorder.Code != 400 || strings.Contains(recorder.Body.String(), secretID) { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} diff --git a/apps/backend/internal/gitlab/service_watch_ownership_test.go b/apps/backend/internal/gitlab/service_watch_ownership_test.go new file mode 100644 index 0000000000..79a8d6ab51 --- /dev/null +++ b/apps/backend/internal/gitlab/service_watch_ownership_test.go @@ -0,0 +1,325 @@ +package gitlab + +import ( + "context" + "errors" + "testing" + + taskservice "github.com/kandev/kandev/internal/task/service" +) + +type barrierCascadeDeleter struct { + entered chan string + release chan struct{} +} + +func (d *barrierCascadeDeleter) DeleteTaskTree(_ context.Context, id string, cascade bool) (*taskservice.CascadeOutcome, error) { + if !cascade { + return nil, errors.New("cascade required") + } + d.entered <- id + <-d.release + return &taskservice.CascadeOutcome{}, nil +} + +type ownershipTaskDeleter struct{ deleted chan string } + +func (d *ownershipTaskDeleter) DeleteTask(_ context.Context, id string) error { + d.deleted <- id + return nil +} + +type watchOwnershipFixture struct { + store *Store + watchID string + watchTable string + generation int64 + reserve func(context.Context, int64, int) (bool, error) + assign func(context.Context, int64, int, string) error + reset func(context.Context) (int, error) + delete func(context.Context) error + loadGen func(context.Context) (int64, bool, error) +} + +func newReviewOwnershipFixture(t *testing.T, cascade *barrierCascadeDeleter, taskDeleter *ownershipTaskDeleter) watchOwnershipFixture { + t.Helper() + store := newTestStore(t) + watch := &ReviewWatch{WorkspaceID: "workspace-a", WorkflowID: "wf", WorkflowStepID: "step", AgentProfileID: "agent", ExecutorProfileID: "exec", Enabled: true} + if err := store.CreateReviewWatch(t.Context(), watch); err != nil { + t.Fatal(err) + } + service := NewService("", nil, AuthMethodNone, nil, newTestLogger(t)) + service.SetStore(store) + service.SetCascadeTaskDeleter(cascade) + service.SetTaskDeleter(taskDeleter) + return watchOwnershipFixture{ + store: store, + watchID: watch.ID, + watchTable: "gitlab_review_watches", + generation: watch.Generation, + reserve: func(ctx context.Context, generation int64, iid int) (bool, error) { + return service.ReserveReviewMRTask(ctx, watch.ID, generation, "group/project", iid, "url") + }, + assign: func(ctx context.Context, generation int64, iid int, taskID string) error { + return service.AssignReviewMRTaskID(ctx, watch.ID, generation, "group/project", iid, taskID) + }, + reset: func(ctx context.Context) (int, error) { return service.ResetReviewWatch(ctx, watch.ID) }, + delete: func(ctx context.Context) error { return service.DeleteReviewWatch(ctx, watch.ID) }, + loadGen: func(ctx context.Context) (int64, bool, error) { + loaded, err := store.GetReviewWatch(ctx, watch.ID) + if loaded == nil { + return 0, false, err + } + return loaded.Generation, true, err + }, + } +} + +func newIssueOwnershipFixture(t *testing.T, cascade *barrierCascadeDeleter, taskDeleter *ownershipTaskDeleter) watchOwnershipFixture { + t.Helper() + store := newTestStore(t) + watch := &IssueWatch{WorkspaceID: "workspace-a", WorkflowID: "wf", WorkflowStepID: "step", AgentProfileID: "agent", ExecutorProfileID: "exec", Enabled: true} + if err := store.CreateIssueWatch(t.Context(), watch); err != nil { + t.Fatal(err) + } + service := NewService("", nil, AuthMethodNone, nil, newTestLogger(t)) + service.SetStore(store) + service.SetCascadeTaskDeleter(cascade) + service.SetTaskDeleter(taskDeleter) + return watchOwnershipFixture{ + store: store, + watchID: watch.ID, + watchTable: "gitlab_issue_watches", + generation: watch.Generation, + reserve: func(ctx context.Context, generation int64, iid int) (bool, error) { + return service.ReserveIssueWatchTask(ctx, watch.ID, generation, "group/project", iid, "url") + }, + assign: func(ctx context.Context, generation int64, iid int, taskID string) error { + return service.AssignIssueWatchTaskID(ctx, watch.ID, generation, "group/project", iid, taskID) + }, + reset: func(ctx context.Context) (int, error) { return service.ResetIssueWatch(ctx, watch.ID) }, + delete: func(ctx context.Context) error { return service.DeleteIssueWatch(ctx, watch.ID) }, + loadGen: func(ctx context.Context) (int64, bool, error) { + loaded, err := store.GetIssueWatch(ctx, watch.ID) + if loaded == nil { + return 0, false, err + } + return loaded.Generation, true, err + }, + } +} + +func TestWatchDeleteCompletesAfterCallerCancellation(t *testing.T) { + for _, test := range []struct { + name string + new func(*testing.T, *barrierCascadeDeleter, *ownershipTaskDeleter) watchOwnershipFixture + }{{"review", newReviewOwnershipFixture}, {"issue", newIssueOwnershipFixture}} { + t.Run(test.name, func(t *testing.T) { + cascade := &barrierCascadeDeleter{entered: make(chan string, 1), release: make(chan struct{})} + t.Cleanup(func() { + select { + case <-cascade.release: + default: + close(cascade.release) + } + }) + fixture := test.new(t, cascade, &ownershipTaskDeleter{deleted: make(chan string, 1)}) + if ok, err := fixture.reserve(t.Context(), fixture.generation, 1); err != nil || !ok { + t.Fatalf("reserve: ok=%v err=%v", ok, err) + } + if err := fixture.assign(t.Context(), fixture.generation, 1, "task-existing"); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + deleteDone := make(chan error, 1) + go func() { deleteDone <- fixture.delete(ctx) }() + if got := <-cascade.entered; got != "task-existing" { + t.Fatalf("delete cleaned %q", got) + } + cancel() + close(cascade.release) + if err := <-deleteDone; err != nil { + t.Fatalf("delete after caller cancellation: %v", err) + } + if _, exists, err := fixture.loadGen(t.Context()); err != nil || exists { + t.Fatalf("watch remains after detached delete: exists=%v err=%v", exists, err) + } + if err := fixture.delete(t.Context()); err != nil { + t.Fatalf("idempotent retry after completed delete: %v", err) + } + }) + } +} + +func TestWatchDeleteResumesTombstoneAfterFinalDeleteFailure(t *testing.T) { + for _, test := range []struct { + name string + new func(*testing.T, *barrierCascadeDeleter, *ownershipTaskDeleter) watchOwnershipFixture + }{{"review", newReviewOwnershipFixture}, {"issue", newIssueOwnershipFixture}} { + t.Run(test.name, func(t *testing.T) { + cascade := &barrierCascadeDeleter{entered: make(chan string, 2), release: make(chan struct{}, 2)} + t.Cleanup(func() { + for range 2 { + select { + case cascade.release <- struct{}{}: + default: + } + } + }) + fixture := test.new(t, cascade, &ownershipTaskDeleter{deleted: make(chan string, 1)}) + if ok, err := fixture.reserve(t.Context(), fixture.generation, 1); err != nil || !ok { + t.Fatalf("reserve: ok=%v err=%v", ok, err) + } + if err := fixture.assign(t.Context(), fixture.generation, 1, "task-existing"); err != nil { + t.Fatal(err) + } + trigger := "fail_" + test.name + "_watch_delete" + if _, err := fixture.store.db.Exec("CREATE TRIGGER " + trigger + " BEFORE DELETE ON " + fixture.watchTable + + " WHEN OLD.id = '" + fixture.watchID + "' BEGIN SELECT RAISE(FAIL, 'forced final delete failure'); END"); err != nil { + t.Fatalf("create failure trigger: %v", err) + } + + firstDone := make(chan error, 1) + go func() { firstDone <- fixture.delete(context.Background()) }() + <-cascade.entered + cascade.release <- struct{}{} + if err := <-firstDone; err == nil { + t.Fatal("delete unexpectedly succeeded with failure trigger") + } + var deleting bool + var generation int64 + if err := fixture.store.ro.QueryRow("SELECT deleting, generation FROM "+fixture.watchTable+" WHERE id = ?", fixture.watchID).Scan(&deleting, &generation); err != nil { + t.Fatalf("load tombstone: %v", err) + } + if !deleting || generation <= fixture.generation { + t.Fatalf("tombstone deleting=%v generation=%d", deleting, generation) + } + if _, err := fixture.store.db.Exec("DROP TRIGGER " + trigger); err != nil { + t.Fatalf("drop failure trigger: %v", err) + } + + retryDone := make(chan error, 1) + go func() { retryDone <- fixture.delete(context.Background()) }() + <-cascade.entered + cascade.release <- struct{}{} + if err := <-retryDone; err != nil { + t.Fatalf("retry tombstone delete: %v", err) + } + if _, exists, err := fixture.loadGen(t.Context()); err != nil || exists { + t.Fatalf("watch remains after retry: exists=%v err=%v", exists, err) + } + }) + } +} + +func TestWatchResetInvalidatesEventReservedBeforeReset(t *testing.T) { + for _, test := range []struct { + name string + new func(*testing.T, *barrierCascadeDeleter, *ownershipTaskDeleter) watchOwnershipFixture + }{{"review", newReviewOwnershipFixture}, {"issue", newIssueOwnershipFixture}} { + t.Run(test.name, func(t *testing.T) { + cascade := &barrierCascadeDeleter{entered: make(chan string, 1), release: make(chan struct{})} + t.Cleanup(func() { + select { + case <-cascade.release: + default: + close(cascade.release) + } + }) + deleted := &ownershipTaskDeleter{deleted: make(chan string, 1)} + fixture := test.new(t, cascade, deleted) + if ok, err := fixture.reserve(t.Context(), fixture.generation, 1); err != nil || !ok { + t.Fatalf("reserve existing task: ok=%v err=%v", ok, err) + } + if err := fixture.assign(t.Context(), fixture.generation, 1, "task-existing"); err != nil { + t.Fatal(err) + } + if ok, err := fixture.reserve(t.Context(), fixture.generation, 2); err != nil || !ok { + t.Fatalf("reserve delayed event: ok=%v err=%v", ok, err) + } + + resetDone := make(chan error, 1) + go func() { + _, err := fixture.reset(context.Background()) + resetDone <- err + }() + if got := <-cascade.entered; got != "task-existing" { + t.Fatalf("reset deleted %q first", got) + } + + if ok, err := fixture.reserve(t.Context(), fixture.generation, 3); err != nil || ok { + t.Fatalf("stale reserve during reset: ok=%v err=%v", ok, err) + } + if err := fixture.assign(t.Context(), fixture.generation, 2, "task-created-late"); !errors.Is(err, ErrWatchOwnershipLost) { + t.Fatalf("stale attach error=%v", err) + } + if got := <-deleted.deleted; got != "task-created-late" { + t.Fatalf("stale task cleanup=%q", got) + } + + close(cascade.release) + if err := <-resetDone; err != nil { + t.Fatalf("reset: %v", err) + } + newGeneration, exists, err := fixture.loadGen(t.Context()) + if err != nil || !exists || newGeneration <= fixture.generation { + t.Fatalf("generation after reset=%d exists=%v err=%v", newGeneration, exists, err) + } + if ok, err := fixture.reserve(t.Context(), newGeneration, 3); err != nil || !ok { + t.Fatalf("current generation reserve: ok=%v err=%v", ok, err) + } + }) + } +} + +func TestWatchDeleteRejectsDelayedOldEvent(t *testing.T) { + for _, test := range []struct { + name string + new func(*testing.T, *barrierCascadeDeleter, *ownershipTaskDeleter) watchOwnershipFixture + }{{"review", newReviewOwnershipFixture}, {"issue", newIssueOwnershipFixture}} { + t.Run(test.name, func(t *testing.T) { + cascade := &barrierCascadeDeleter{entered: make(chan string, 1), release: make(chan struct{})} + t.Cleanup(func() { + select { + case <-cascade.release: + default: + close(cascade.release) + } + }) + deleted := &ownershipTaskDeleter{deleted: make(chan string, 1)} + fixture := test.new(t, cascade, deleted) + if ok, err := fixture.reserve(t.Context(), fixture.generation, 1); err != nil || !ok { + t.Fatalf("reserve existing task: ok=%v err=%v", ok, err) + } + if err := fixture.assign(t.Context(), fixture.generation, 1, "task-existing"); err != nil { + t.Fatal(err) + } + if ok, err := fixture.reserve(t.Context(), fixture.generation, 2); err != nil || !ok { + t.Fatalf("reserve delayed event: ok=%v err=%v", ok, err) + } + + deleteDone := make(chan error, 1) + go func() { deleteDone <- fixture.delete(context.Background()) }() + if got := <-cascade.entered; got != "task-existing" { + t.Fatalf("delete cleaned %q first", got) + } + if ok, err := fixture.reserve(t.Context(), fixture.generation, 3); err != nil || ok { + t.Fatalf("stale reserve during delete: ok=%v err=%v", ok, err) + } + if err := fixture.assign(t.Context(), fixture.generation, 2, "task-created-late"); !errors.Is(err, ErrWatchOwnershipLost) { + t.Fatalf("stale attach error=%v", err) + } + if got := <-deleted.deleted; got != "task-created-late" { + t.Fatalf("stale task cleanup=%q", got) + } + close(cascade.release) + if err := <-deleteDone; err != nil { + t.Fatalf("delete: %v", err) + } + if _, exists, err := fixture.loadGen(t.Context()); err != nil || exists { + t.Fatalf("watch remains after delete: exists=%v err=%v", exists, err) + } + }) + } +} diff --git a/apps/backend/internal/gitlab/service_watch_reset.go b/apps/backend/internal/gitlab/service_watch_reset.go new file mode 100644 index 0000000000..d03d50a29f --- /dev/null +++ b/apps/backend/internal/gitlab/service_watch_reset.go @@ -0,0 +1,161 @@ +package gitlab + +import ( + "context" + "errors" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/watchreset" +) + +type reviewWatchResetter struct { + store *Store + watchID string +} + +func (r reviewWatchResetter) ListTaskIDs(ctx context.Context) ([]string, error) { + return r.store.ListReviewMRTaskIDsByWatch(ctx, r.watchID) +} + +func (r reviewWatchResetter) Clear(ctx context.Context) error { + return r.store.ResetReviewWatchState(ctx, r.watchID) +} + +type issueWatchResetter struct { + store *Store + watchID string +} + +func (r issueWatchResetter) ListTaskIDs(ctx context.Context) ([]string, error) { + return r.store.ListIssueWatchTaskIDsByWatch(ctx, r.watchID) +} + +func (r issueWatchResetter) Clear(ctx context.Context) error { + return r.store.ResetIssueWatchState(ctx, r.watchID) +} + +func (s *Service) PreviewResetReviewWatch(ctx context.Context, watchID string) (int, error) { + store := s.requireStore() + if store == nil { + return 0, errStoreUnavailable + } + return watchreset.Preview(ctx, reviewWatchResetter{store: store, watchID: watchID}) +} + +func (s *Service) DisableReviewWatchWithError(ctx context.Context, watchID, cause string) error { + store := s.requireStore() + if store == nil { + return errStoreUnavailable + } + return store.DisableReviewWatchWithError(ctx, watchID, cause) +} + +func (s *Service) ResetReviewWatch(ctx context.Context, watchID string) (int, error) { + store := s.requireStore() + if store == nil { + return 0, errStoreUnavailable + } + s.mu.RLock() + deleter := s.cascadeTaskDeleter + s.mu.RUnlock() + if deleter == nil { + return 0, errors.New("gitlab: cascade task deleter not wired; reset unavailable") + } + if err := ctx.Err(); err != nil { + return 0, err + } + reimportCtx := ctx + ctx = context.WithoutCancel(ctx) + invalidation, err := store.BeginReviewWatchReset(ctx, watchID) + if err != nil { + return 0, err + } + deleted := s.deleteInvalidatedWatchTasks(ctx, watchID, invalidation.TaskIDs, deleter) + err = store.FinishReviewWatchReset(ctx, watchID, invalidation.Generation) + if err == nil { + s.reimportReviewWatchAfterReset(reimportCtx, watchID) + } + return deleted, err +} + +func (s *Service) reimportReviewWatchAfterReset(ctx context.Context, watchID string) { + watch, err := s.GetReviewWatch(ctx, watchID) + if err != nil || watch == nil || !watch.Enabled { + if err != nil { + s.logger.Warn("reset review watch: load for re-import", zap.String("watch_id", watchID), zap.Error(err)) + } + return + } + if _, err := s.TriggerReviewWatch(ctx, watchID); err != nil { + s.logger.Warn("reset review watch: re-import failed", zap.String("watch_id", watchID), zap.Error(err)) + } +} + +func (s *Service) PreviewResetIssueWatch(ctx context.Context, watchID string) (int, error) { + store := s.requireStore() + if store == nil { + return 0, errStoreUnavailable + } + return watchreset.Preview(ctx, issueWatchResetter{store: store, watchID: watchID}) +} + +func (s *Service) DisableIssueWatchWithError(ctx context.Context, watchID, cause string) error { + store := s.requireStore() + if store == nil { + return errStoreUnavailable + } + return store.DisableIssueWatchWithError(ctx, watchID, cause) +} + +func (s *Service) ResetIssueWatch(ctx context.Context, watchID string) (int, error) { + store := s.requireStore() + if store == nil { + return 0, errStoreUnavailable + } + s.mu.RLock() + deleter := s.cascadeTaskDeleter + s.mu.RUnlock() + if deleter == nil { + return 0, errors.New("gitlab: cascade task deleter not wired; reset unavailable") + } + if err := ctx.Err(); err != nil { + return 0, err + } + ctx = context.WithoutCancel(ctx) + invalidation, err := store.BeginIssueWatchReset(ctx, watchID) + if err != nil { + return 0, err + } + deleted := s.deleteInvalidatedWatchTasks(ctx, watchID, invalidation.TaskIDs, deleter) + err = store.FinishIssueWatchReset(ctx, watchID, invalidation.Generation) + return deleted, err +} + +func (s *Service) deleteInvalidatedWatchTasks(ctx context.Context, watchID string, ids []string, deleter watchreset.TaskDeleter) int { + deleted := 0 + for _, id := range ids { + if id == "" { + continue + } + if _, err := deleter.DeleteTaskTree(ctx, id, true); err != nil { + s.logger.Warn("watch reset: task tree cleanup failed", + zap.String("watch_id", watchID), zap.String("task_id", id), zap.Error(err)) + continue + } + deleted++ + } + return deleted +} + +func (s *Service) deleteWatchTaskTrees(ctx context.Context, watchID string, ids []string, deleter watchreset.TaskDeleter) { + for _, id := range ids { + if id == "" { + continue + } + if _, err := deleter.DeleteTaskTree(ctx, id, true); err != nil { + s.logger.Warn("delete watch: task tree cleanup failed", + zap.String("watch_id", watchID), zap.String("task_id", id), zap.Error(err)) + } + } +} diff --git a/apps/backend/internal/gitlab/service_watch_reset_test.go b/apps/backend/internal/gitlab/service_watch_reset_test.go new file mode 100644 index 0000000000..5775e5fa1c --- /dev/null +++ b/apps/backend/internal/gitlab/service_watch_reset_test.go @@ -0,0 +1,123 @@ +package gitlab + +import ( + "context" + "errors" + "testing" + + taskservice "github.com/kandev/kandev/internal/task/service" +) + +type recordingCascadeDeleter struct { + ids []string + failOn string +} + +func (d *recordingCascadeDeleter) DeleteTaskTree(_ context.Context, id string, cascade bool) (*taskservice.CascadeOutcome, error) { + if !cascade { + panic("watch reset must cascade") + } + d.ids = append(d.ids, id) + if id == d.failOn { + return nil, errors.New("delete failed") + } + return &taskservice.CascadeOutcome{}, nil +} + +func TestServiceResetIssueWatchAndDeleteContinueAfterTaskFailure(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + watch := &IssueWatch{ + WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", + AgentProfileID: "agent-1", ExecutorProfileID: "exec-1", Enabled: true, + } + if err := store.CreateIssueWatch(ctx, watch); err != nil { + t.Fatalf("create watch: %v", err) + } + for iid, taskID := range []string{"task-fails", "task-ok"} { + if ok, err := store.ReserveIssueWatchTask(ctx, watch.ID, "group/project", iid+1, "https://gitlab/issue"); err != nil || !ok { + t.Fatalf("reserve %d: ok=%v err=%v", iid, ok, err) + } + if err := store.AssignIssueWatchTaskID(ctx, watch.ID, "group/project", iid+1, taskID); err != nil { + t.Fatalf("assign %d: %v", iid, err) + } + } + svc := NewService("", nil, "none", nil, newTestLogger(t)) + svc.SetStore(store) + deleter := &recordingCascadeDeleter{failOn: "task-fails"} + svc.SetCascadeTaskDeleter(deleter) + + deleted, err := svc.ResetIssueWatch(ctx, watch.ID) + if err != nil { + t.Fatalf("reset: %v", err) + } + if deleted != 1 || len(deleter.ids) != 2 { + t.Fatalf("deleted=%d calls=%v", deleted, deleter.ids) + } + rows, err := store.ListIssueWatchTasksByWatch(ctx, watch.ID) + if err != nil || len(rows) != 0 { + t.Fatalf("dedup after reset=%v err=%v", rows, err) + } + gotWatch, err := store.GetIssueWatch(ctx, watch.ID) + if err != nil || gotWatch == nil || !gotWatch.Enabled || gotWatch.LastPolledAt != nil { + t.Fatalf("watch after reset=%#v err=%v", gotWatch, err) + } + + if ok, err := store.ReserveIssueWatchTask(ctx, watch.ID, "group/project", 3, "https://gitlab/issue"); err != nil || !ok { + t.Fatalf("reserve after reset: ok=%v err=%v", ok, err) + } + if err := svc.DeleteIssueWatch(ctx, watch.ID); err != nil { + t.Fatalf("delete watch: %v", err) + } + gotWatch, err = store.GetIssueWatch(ctx, watch.ID) + if err != nil || gotWatch != nil { + t.Fatalf("watch after delete=%#v err=%v", gotWatch, err) + } + rows, err = store.ListIssueWatchTasksByWatch(ctx, watch.ID) + if err != nil || len(rows) != 0 { + t.Fatalf("dedup after delete=%v err=%v", rows, err) + } +} + +func TestServiceResetReviewWatchDeletesTasksAndClearsState(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + watch := &ReviewWatch{ + WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", + AgentProfileID: "agent-1", ExecutorProfileID: "exec-1", Enabled: false, + } + if err := store.CreateReviewWatch(ctx, watch); err != nil { + t.Fatalf("create watch: %v", err) + } + for iid, taskID := range []string{"task-1", "", "task-2"} { + if ok, err := store.ReserveReviewMRTask(ctx, watch.ID, "group/project", iid+1, "https://gitlab/mr"); err != nil || !ok { + t.Fatalf("reserve %d: ok=%v err=%v", iid, ok, err) + } + if taskID != "" { + if err := store.AssignReviewMRTaskID(ctx, watch.ID, "group/project", iid+1, taskID); err != nil { + t.Fatalf("assign %d: %v", iid, err) + } + } + } + + svc := NewService("", nil, "none", nil, newTestLogger(t)) + svc.SetStore(store) + deleter := &recordingCascadeDeleter{} + svc.SetCascadeTaskDeleter(deleter) + + preview, err := svc.PreviewResetReviewWatch(ctx, watch.ID) + if err != nil || preview != 2 { + t.Fatalf("preview=%d err=%v, want 2", preview, err) + } + deleted, err := svc.ResetReviewWatch(ctx, watch.ID) + if err != nil { + t.Fatalf("reset: %v", err) + } + if deleted != 2 || len(deleter.ids) != 2 { + t.Fatalf("deleted=%d calls=%v", deleted, deleter.ids) + } + rows, err := store.ListReviewMRTasksByWatch(ctx, watch.ID) + if err != nil || len(rows) != 0 { + t.Fatalf("dedup after reset=%v err=%v", rows, err) + } +} diff --git a/apps/backend/internal/gitlab/service_watches.go b/apps/backend/internal/gitlab/service_watches.go index 5efd8e37ed..6ba56a3189 100644 --- a/apps/backend/internal/gitlab/service_watches.go +++ b/apps/backend/internal/gitlab/service_watches.go @@ -8,6 +8,8 @@ import ( "time" "go.uber.org/zap" + + "github.com/kandev/kandev/internal/watchreset" ) // eventSource is the `source` field on every bus.Event published by this @@ -17,6 +19,7 @@ const eventSource = "gitlab" const ( defaultWatchPollIntervalSec = 300 minWatchPollIntervalSec = 60 + watchDeleteTimeout = 30 * time.Second ) // --- MR Watch --- @@ -99,6 +102,45 @@ func (s *Service) DeleteMRWatch(ctx context.Context, id string) error { return store.DeleteMRWatch(ctx, id) } +func (s *Service) ListMRWatchesBySessionForWorkspace(ctx context.Context, workspaceID, sessionID string) ([]*MRWatch, error) { + store := s.requireStore() + if store == nil { + return nil, errStoreUnavailable + } + return store.ListMRWatchesBySessionForWorkspace(ctx, workspaceID, sessionID) +} + +func (s *Service) ListMRWatchesByTaskForWorkspace(ctx context.Context, workspaceID, taskID string) ([]*MRWatch, error) { + store := s.requireStore() + if store == nil { + return nil, errStoreUnavailable + } + return store.ListMRWatchesByTaskForWorkspace(ctx, workspaceID, taskID) +} + +func (s *Service) ListActiveMRWatchesForWorkspace(ctx context.Context, workspaceID string) ([]*MRWatch, error) { + store := s.requireStore() + if store == nil { + return nil, errStoreUnavailable + } + return store.ListActiveMRWatchesForWorkspace(ctx, workspaceID) +} + +func (s *Service) DeleteMRWatchForWorkspace(ctx context.Context, workspaceID, id string) error { + store := s.requireStore() + if store == nil { + return errStoreUnavailable + } + deleted, err := store.DeleteMRWatchForWorkspace(ctx, workspaceID, id) + if err != nil { + return err + } + if !deleted { + return ErrWatchNotFound + } + return nil +} + // errStoreUnavailable is returned by Service methods that require the // SQLite store to be wired but the runtime didn't manage to create it // (table migration failure on boot). Distinct error so callers can render @@ -110,6 +152,10 @@ var errStoreUnavailable = fmt.Errorf("gitlab store not configured") // than 500. var ErrWatchNotFound = fmt.Errorf("watch not found") +// ErrWatchOwnershipLost means an event belongs to a watch generation that was +// invalidated by reset or delete. Callers must not retain the created task. +var ErrWatchOwnershipLost = fmt.Errorf("watch ownership lost") + // CheckMRWatch polls a watch once: returns the latest MR status and whether // the underlying MR moved into a state worth notifying about (new note, // pipeline transition, approval transition). @@ -117,9 +163,9 @@ func (s *Service) CheckMRWatch(ctx context.Context, watch *MRWatch) (*MRStatus, if watch == nil { return nil, false, fmt.Errorf("watch is nil") } - client := s.Client() - if client == nil { - return nil, false, ErrNoClient + client, err := s.clientForTask(ctx, watch.TaskID) + if err != nil { + return nil, false, err } store := s.requireStore() if store == nil { @@ -164,6 +210,13 @@ func (s *Service) CreateReviewWatch(ctx context.Context, req *CreateReviewWatchR if !IsValidCleanupPolicy(req.CleanupPolicy) { return nil, fmt.Errorf("invalid cleanup_policy: %q", req.CleanupPolicy) } + if err := s.validateWatchDependencies(ctx, req.WorkspaceID, req.WorkflowID, req.WorkflowStepID, req.AgentProfileID, req.ExecutorProfileID); err != nil { + return nil, err + } + repositoryID, baseBranch, err := s.resolveWatchRepository(ctx, req.WorkspaceID, req.RepositoryID, req.BaseBranch) + if err != nil { + return nil, err + } interval := req.PollIntervalSeconds if interval <= 0 { interval = defaultWatchPollIntervalSec @@ -183,11 +236,17 @@ func (s *Service) CreateReviewWatch(ctx context.Context, req *CreateReviewWatchR AgentProfileID: req.AgentProfileID, ExecutorProfileID: req.ExecutorProfileID, Prompt: req.Prompt, + RepositoryID: repositoryID, + BaseBranch: baseBranch, ReviewScope: scope, CustomQuery: req.CustomQuery, Enabled: true, PollIntervalSeconds: interval, CleanupPolicy: NormalizeCleanupPolicy(req.CleanupPolicy), + MaxInflightTasks: req.MaxInflightTasks, + } + if err := validateWatchMaxInflight(rw.MaxInflightTasks); err != nil { + return nil, err } store := s.requireStore() if store == nil { @@ -221,6 +280,14 @@ func (s *Service) GetReviewWatch(ctx context.Context, id string) (*ReviewWatch, return store.GetReviewWatch(ctx, id) } +func (s *Service) GetReviewWatchIncludingDeleting(ctx context.Context, id string) (*ReviewWatch, error) { + store := s.requireStore() + if store == nil { + return nil, errStoreUnavailable + } + return store.GetReviewWatchIncludingDeleting(ctx, id) +} + // ListReviewWatches lists review watches in a workspace. func (s *Service) ListReviewWatches(ctx context.Context, workspaceID string) ([]*ReviewWatch, error) { store := s.requireStore() @@ -256,9 +323,22 @@ func (s *Service) UpdateReviewWatch(ctx context.Context, id string, req *UpdateR return fmt.Errorf("%w: review watch %s", ErrWatchNotFound, id) } applyReviewWatchPatch(rw, req) + if err := s.validateWatchDependencies(ctx, rw.WorkspaceID, rw.WorkflowID, rw.WorkflowStepID, rw.AgentProfileID, rw.ExecutorProfileID); err != nil { + return err + } + if req.RepositoryID != nil || req.BaseBranch != nil { + repositoryID, baseBranch, err := s.resolveWatchRepository(ctx, rw.WorkspaceID, rw.RepositoryID, rw.BaseBranch) + if err != nil { + return err + } + rw.RepositoryID, rw.BaseBranch = repositoryID, baseBranch + } if req.CleanupPolicy != nil && !IsValidCleanupPolicy(*req.CleanupPolicy) { return fmt.Errorf("invalid cleanup_policy: %q", *req.CleanupPolicy) } + if err := validateWatchMaxInflight(rw.MaxInflightTasks); err != nil { + return err + } return store.UpdateReviewWatch(ctx, rw) } @@ -287,6 +367,15 @@ func applyReviewWatchPatch(rw *ReviewWatch, req *UpdateReviewWatchRequest) { if req.Prompt != nil { rw.Prompt = *req.Prompt } + if req.RepositoryID != nil { + rw.RepositoryID = *req.RepositoryID + if rw.RepositoryID == "" { + rw.BaseBranch = "" + } + } + if req.BaseBranch != nil && rw.RepositoryID != "" { + rw.BaseBranch = *req.BaseBranch + } if req.ReviewScope != nil { rw.ReviewScope = *req.ReviewScope } @@ -295,6 +384,10 @@ func applyReviewWatchPatch(rw *ReviewWatch, req *UpdateReviewWatchRequest) { } if req.Enabled != nil { rw.Enabled = *req.Enabled + if rw.Enabled { + rw.LastError = "" + rw.LastErrorAt = nil + } } if req.PollIntervalSeconds != nil { rw.PollIntervalSeconds = clampPollInterval(*req.PollIntervalSeconds) @@ -302,6 +395,13 @@ func applyReviewWatchPatch(rw *ReviewWatch, req *UpdateReviewWatchRequest) { if req.CleanupPolicy != nil { rw.CleanupPolicy = NormalizeCleanupPolicy(*req.CleanupPolicy) } + if req.MaxInflightTasks != nil { + if *req.MaxInflightTasks == 0 { + rw.MaxInflightTasks = nil + } else { + rw.MaxInflightTasks = req.MaxInflightTasks + } + } } // clampPollInterval enforces the same bounds the create path applies (0 → default, @@ -316,6 +416,13 @@ func clampPollInterval(seconds int) int { return seconds } +func validateWatchMaxInflight(max *int) error { + if max != nil && *max <= 0 { + return fmt.Errorf("max_inflight_tasks must be positive") + } + return nil +} + // DeleteReviewWatch removes a review watch and best-effort reaps any tasks // it owned (tasks survive when the dedup row dies, so pre-sweep first). func (s *Service) DeleteReviewWatch(ctx context.Context, id string) error { @@ -323,31 +430,41 @@ func (s *Service) DeleteReviewWatch(ctx context.Context, id string) error { if store == nil { return errStoreUnavailable } + if err := ctx.Err(); err != nil { + return err + } + deleteCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), watchDeleteTimeout) + defer cancel() s.mu.RLock() deleter := s.taskDeleter + cascadeDeleter := s.cascadeTaskDeleter s.mu.RUnlock() - if deleter != nil { - mrTasks, err := store.ListReviewMRTasksByWatch(ctx, id) - if err != nil { - s.logger.Warn("failed to list review MR tasks for pre-delete sweep", - zap.String("watch_id", id), zap.Error(err)) - } else { - s.sweepReviewWatchTasks(ctx, id, mrTasks, deleter) - } + invalidation, err := store.BeginReviewWatchDelete(deleteCtx, id) + if err != nil { + return err } - return store.DeleteReviewWatch(ctx, id) + if invalidation.Missing { + return nil + } + s.cleanupInvalidatedWatchTasks(deleteCtx, id, invalidation.TaskIDs, cascadeDeleter, deleter) + return store.DeleteReviewWatch(deleteCtx, id) } -func (s *Service) sweepReviewWatchTasks(ctx context.Context, watchID string, tasks []*ReviewMRTask, deleter TaskDeleter) { - for _, t := range tasks { - if t.TaskID == "" { +func (s *Service) cleanupInvalidatedWatchTasks(ctx context.Context, watchID string, ids []string, cascadeDeleter watchreset.TaskDeleter, deleter TaskDeleter) { + if cascadeDeleter != nil { + s.deleteWatchTaskTrees(ctx, watchID, ids, cascadeDeleter) + return + } + if deleter == nil { + return + } + for _, taskID := range ids { + if taskID == "" { continue } - if err := deleter.DeleteTask(ctx, t.TaskID); err != nil { - s.logger.Warn("failed to delete review task during watch cleanup", - zap.String("watch_id", watchID), - zap.String("task_id", t.TaskID), - zap.Error(err)) + if err := deleter.DeleteTask(ctx, taskID); err != nil { + s.logger.Warn("failed to delete task during watch cleanup", + zap.String("watch_id", watchID), zap.String("task_id", taskID), zap.Error(err)) } } } @@ -367,10 +484,6 @@ func (s *Service) CheckReviewWatch(ctx context.Context, watch *ReviewWatch) ([]* if !watch.Enabled { return nil, nil } - client := s.Client() - if client == nil { - return nil, ErrNoClient - } store := s.requireStore() if store == nil { return nil, errStoreUnavailable @@ -398,7 +511,10 @@ func (s *Service) CheckReviewWatch(ctx context.Context, watch *ReviewWatch) ([]* } func (s *Service) fetchReviewMRs(ctx context.Context, watch *ReviewWatch) ([]*MR, error) { - client := s.Client() + client, err := s.ClientForWorkspace(ctx, watch.WorkspaceID) + if err != nil { + return nil, err + } username, err := client.GetAuthenticatedUser(ctx) if err != nil { return nil, fmt.Errorf("resolve gitlab username: %w", err) @@ -477,6 +593,31 @@ func (s *Service) TriggerReviewWatchAll(ctx context.Context) (int, error) { return total, nil } +// TriggerReviewWatchAllForWorkspace runs only watches owned by workspaceID. +func (s *Service) TriggerReviewWatchAllForWorkspace(ctx context.Context, workspaceID string) (int, error) { + store := s.requireStore() + if store == nil { + return 0, errStoreUnavailable + } + watches, err := store.ListEnabledReviewWatchesForWorkspace(ctx, workspaceID) + if err != nil { + return 0, err + } + total := 0 + for _, watch := range watches { + found, checkErr := s.CheckReviewWatch(ctx, watch) + if checkErr != nil { + s.logger.Warn("trigger workspace review watches", zap.String("watch_id", watch.ID), zap.Error(checkErr)) + continue + } + for _, mr := range found { + s.publishNewReviewMREvent(ctx, watch, mr) + } + total += len(found) + } + return total, nil +} + // --- Helpers --- func (s *Service) requireStore() *Store { diff --git a/apps/backend/internal/gitlab/store.go b/apps/backend/internal/gitlab/store.go index 38aad2bd94..a899505123 100644 --- a/apps/backend/internal/gitlab/store.go +++ b/apps/backend/internal/gitlab/store.go @@ -35,6 +35,22 @@ func NewStore(db, ro *sqlx.DB) (*Store, error) { } const createTablesSQL = ` + CREATE TABLE IF NOT EXISTS gitlab_configs ( + workspace_id TEXT PRIMARY KEY, + host TEXT NOT NULL, + auth_method TEXT NOT NULL, + username TEXT NOT NULL DEFAULT '', + last_ok INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + last_checked_at DATETIME, + revision INTEGER NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + -- The workspaces table is created before integration stores in production. + -- Isolated package tests may omit it, which SQLite permits at DDL time. + , FOREIGN KEY(workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS gitlab_task_mrs ( id TEXT PRIMARY KEY, task_id TEXT NOT NULL, @@ -92,11 +108,18 @@ const createTablesSQL = ` agent_profile_id TEXT NOT NULL, executor_profile_id TEXT NOT NULL, prompt TEXT DEFAULT '', + repository_id TEXT NOT NULL DEFAULT '', + base_branch TEXT NOT NULL DEFAULT '', review_scope TEXT NOT NULL DEFAULT 'user_and_teams', custom_query TEXT NOT NULL DEFAULT '', enabled BOOLEAN DEFAULT 1, poll_interval_seconds INTEGER DEFAULT 300, cleanup_policy TEXT NOT NULL DEFAULT 'auto', + max_inflight_tasks INTEGER, + generation INTEGER NOT NULL DEFAULT 1, + deleting BOOLEAN NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + last_error_at DATETIME, last_polled_at DATETIME, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL @@ -110,6 +133,7 @@ const createTablesSQL = ` mr_iid INTEGER NOT NULL, mr_url TEXT NOT NULL, task_id TEXT NOT NULL, + generation INTEGER NOT NULL DEFAULT 1, created_at DATETIME NOT NULL, UNIQUE(review_watch_id, project_path, mr_iid) ); @@ -123,11 +147,18 @@ const createTablesSQL = ` agent_profile_id TEXT NOT NULL, executor_profile_id TEXT NOT NULL, prompt TEXT DEFAULT '', + repository_id TEXT NOT NULL DEFAULT '', + base_branch TEXT NOT NULL DEFAULT '', labels TEXT NOT NULL DEFAULT '[]', custom_query TEXT NOT NULL DEFAULT '', enabled BOOLEAN DEFAULT 1, poll_interval_seconds INTEGER DEFAULT 300, cleanup_policy TEXT NOT NULL DEFAULT 'auto', + max_inflight_tasks INTEGER, + generation INTEGER NOT NULL DEFAULT 1, + deleting BOOLEAN NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + last_error_at DATETIME, last_polled_at DATETIME, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL @@ -141,6 +172,7 @@ const createTablesSQL = ` issue_iid INTEGER NOT NULL, issue_url TEXT NOT NULL, task_id TEXT NOT NULL, + generation INTEGER NOT NULL DEFAULT 1, created_at DATETIME NOT NULL, UNIQUE(issue_watch_id, project_path, issue_iid) ); @@ -154,8 +186,87 @@ const createTablesSQL = ` ` func (s *Store) createTables() error { - _, err := s.db.Exec(createTablesSQL) - return err + if _, err := s.db.Exec(createTablesSQL); err != nil { + return err + } + if err := s.migrateConfigRevision(); err != nil { + return err + } + return s.migrateWatchColumns() +} + +func (s *Store) migrateConfigRevision() error { + existing, err := s.tableColumns("gitlab_configs") + if err != nil { + return err + } + if _, ok := existing["revision"]; ok { + return nil + } + if _, err := s.db.Exec(`ALTER TABLE gitlab_configs ADD COLUMN revision INTEGER NOT NULL DEFAULT 1`); err != nil { + return fmt.Errorf("migrate gitlab_configs.revision: %w", err) + } + return nil +} + +func (s *Store) migrateWatchColumns() error { + columns := []struct { + name string + ddl string + }{ + {"repository_id", "TEXT NOT NULL DEFAULT ''"}, + {"base_branch", "TEXT NOT NULL DEFAULT ''"}, + {"max_inflight_tasks", "INTEGER"}, + {"last_error", "TEXT NOT NULL DEFAULT ''"}, + {"last_error_at", "DATETIME"}, + {"generation", "INTEGER NOT NULL DEFAULT 1"}, + {"deleting", "BOOLEAN NOT NULL DEFAULT 0"}, + } + for _, table := range []string{"gitlab_review_watches", "gitlab_issue_watches"} { + existing, err := s.tableColumns(table) + if err != nil { + return err + } + for _, column := range columns { + if _, ok := existing[column.name]; ok { + continue + } + if _, err := s.db.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column.name, column.ddl)); err != nil { + return fmt.Errorf("migrate %s.%s: %w", table, column.name, err) + } + } + } + for _, table := range []string{"gitlab_review_mr_tasks", "gitlab_issue_watch_tasks"} { + existing, err := s.tableColumns(table) + if err != nil { + return err + } + if _, ok := existing["generation"]; !ok { + if _, err := s.db.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN generation INTEGER NOT NULL DEFAULT 1", table)); err != nil { + return fmt.Errorf("migrate %s.generation: %w", table, err) + } + } + } + return nil +} + +func (s *Store) tableColumns(table string) (map[string]struct{}, error) { + rows, err := s.db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table)) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + columns := make(map[string]struct{}) + for rows.Next() { + var cid, notNull, primaryKey int + var name, columnType string + var defaultValue sql.NullString + if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &primaryKey); err != nil { + return nil, err + } + columns[name] = struct{}{} + } + return columns, rows.Err() } // taskMRSelectCols is the projection used by every SELECT so dev DBs that @@ -184,22 +295,13 @@ const taskMRSelectColsQualified = `gtm.id, gtm.task_id, gtm.repository_id, func (s *Store) UpsertTaskMR(ctx context.Context, tm *TaskMR) error { now := time.Now().UTC() tm.UpdatedAt = now - existing, err := s.findTaskMR(ctx, tm.TaskID, tm.RepositoryID, tm.ProjectPath, tm.MRIID) - if err != nil { - return err - } - if existing != nil { - tm.ID = existing.ID - tm.CreatedAt = existing.CreatedAt - return s.updateTaskMR(ctx, tm) - } if tm.ID == "" { tm.ID = uuid.New().String() } if tm.CreatedAt.IsZero() { tm.CreatedAt = now } - _, err = s.db.NamedExecContext(ctx, ` + rows, err := s.db.NamedQueryContext(ctx, ` INSERT INTO gitlab_task_mrs ( id, task_id, repository_id, host, project_path, mr_iid, mr_url, mr_title, head_branch, base_branch, author_username, state, approval_state, pipeline_state, @@ -212,39 +314,31 @@ func (s *Store) UpsertTaskMR(ctx context.Context, tm *TaskMR) error { :merge_status, :draft, :approval_count, :required_approvals, :pipeline_jobs_total, :pipeline_jobs_pass, :created_at, :merged_at, :closed_at, :last_synced_at, :updated_at - )`, tm) - return err -} - -func (s *Store) updateTaskMR(ctx context.Context, tm *TaskMR) error { - _, err := s.db.NamedExecContext(ctx, ` - UPDATE gitlab_task_mrs SET - host = :host, mr_url = :mr_url, mr_title = :mr_title, - head_branch = :head_branch, base_branch = :base_branch, - author_username = :author_username, state = :state, - approval_state = :approval_state, pipeline_state = :pipeline_state, - merge_status = :merge_status, draft = :draft, - approval_count = :approval_count, required_approvals = :required_approvals, - pipeline_jobs_total = :pipeline_jobs_total, pipeline_jobs_pass = :pipeline_jobs_pass, - merged_at = :merged_at, closed_at = :closed_at, - last_synced_at = :last_synced_at, updated_at = :updated_at - WHERE id = :id`, tm) - return err -} - -func (s *Store) findTaskMR(ctx context.Context, taskID, repositoryID, projectPath string, iid int) (*TaskMR, error) { - var tm TaskMR - err := s.ro.GetContext(ctx, &tm, - `SELECT `+taskMRSelectCols+` FROM gitlab_task_mrs - WHERE task_id = ? AND repository_id = ? AND project_path = ? AND mr_iid = ? - LIMIT 1`, taskID, repositoryID, projectPath, iid) - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } + ) + ON CONFLICT(task_id, repository_id, project_path, mr_iid) DO UPDATE SET + host = excluded.host, mr_url = excluded.mr_url, mr_title = excluded.mr_title, + head_branch = excluded.head_branch, base_branch = excluded.base_branch, + author_username = excluded.author_username, state = excluded.state, + approval_state = excluded.approval_state, pipeline_state = excluded.pipeline_state, + merge_status = excluded.merge_status, draft = excluded.draft, + approval_count = excluded.approval_count, + required_approvals = excluded.required_approvals, + pipeline_jobs_total = excluded.pipeline_jobs_total, + pipeline_jobs_pass = excluded.pipeline_jobs_pass, + merged_at = excluded.merged_at, closed_at = excluded.closed_at, + last_synced_at = excluded.last_synced_at, updated_at = excluded.updated_at + RETURNING id, created_at, updated_at`, tm) if err != nil { - return nil, err + return err } - return &tm, nil + defer func() { _ = rows.Close() }() + if !rows.Next() { + if err := rows.Err(); err != nil { + return err + } + return errors.New("upsert task MR returned no row") + } + return rows.Scan(&tm.ID, &tm.CreatedAt, &tm.UpdatedAt) } // ListTaskMRsByTask returns every MR association for a task, oldest first. diff --git a/apps/backend/internal/gitlab/store_config.go b/apps/backend/internal/gitlab/store_config.go new file mode 100644 index 0000000000..0e1aa7912e --- /dev/null +++ b/apps/backend/internal/gitlab/store_config.go @@ -0,0 +1,153 @@ +package gitlab + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/jmoiron/sqlx" +) + +type configExecer interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) + QueryRowxContext(context.Context, string, ...any) *sqlx.Row +} + +const configSelectColumns = `workspace_id, host, auth_method, username, + last_ok, last_error, last_checked_at, revision, created_at, updated_at` + +// GetConfigForWorkspace returns one workspace's config or nil when absent. +func (s *Store) GetConfigForWorkspace(ctx context.Context, workspaceID string) (*GitLabConfig, error) { + var cfg GitLabConfig + err := s.ro.GetContext(ctx, &cfg, `SELECT `+configSelectColumns+` + FROM gitlab_configs WHERE workspace_id = ?`, workspaceID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &cfg, nil +} + +// UpsertConfigForWorkspace writes connection metadata without touching health. +func (s *Store) UpsertConfigForWorkspace(ctx context.Context, workspaceID string, cfg *GitLabConfig) error { + now := time.Now().UTC() + return upsertConfigForWorkspace(ctx, s.db, workspaceID, cfg, now) +} + +func upsertConfigForWorkspace(ctx context.Context, execer configExecer, workspaceID string, cfg *GitLabConfig, now time.Time) error { + if cfg.CreatedAt.IsZero() { + cfg.CreatedAt = now + } + cfg.WorkspaceID = workspaceID + cfg.UpdatedAt = now + err := execer.QueryRowxContext(ctx, ` + INSERT INTO gitlab_configs ( + workspace_id, host, auth_method, username, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(workspace_id) DO UPDATE SET + host = excluded.host, + auth_method = excluded.auth_method, + username = excluded.username, + updated_at = excluded.updated_at, + revision = gitlab_configs.revision + 1 + RETURNING revision`, + workspaceID, cfg.Host, cfg.AuthMethod, cfg.Username, cfg.CreatedAt, cfg.UpdatedAt). + Scan(&cfg.Revision) + return err +} + +// SaveConfigForWorkspace atomically replaces metadata and records the +// successful credential probe. A failure in either statement leaves the +// previous row untouched. +func (s *Store) SaveConfigForWorkspace(ctx context.Context, workspaceID string, cfg *GitLabConfig) error { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + checkedAt := time.Now().UTC() + if err := upsertConfigForWorkspace(ctx, tx, workspaceID, cfg, checkedAt); err != nil { + return err + } + updated, err := updateConfigHealthForRevision(ctx, tx, workspaceID, cfg.Username, true, "", checkedAt, cfg.Revision) + if err != nil { + return err + } + if !updated { + return errors.New("gitlab config revision changed during save") + } + return tx.Commit() +} + +// DeleteConfigForWorkspace removes only the selected workspace connection. +func (s *Store) DeleteConfigForWorkspace(ctx context.Context, workspaceID string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM gitlab_configs WHERE workspace_id = ?`, workspaceID) + return err +} + +// ListConfigWorkspaceIDs returns every workspace with a configured connection. +func (s *Store) ListConfigWorkspaceIDs(ctx context.Context) ([]string, error) { + var ids []string + err := s.ro.SelectContext(ctx, &ids, `SELECT workspace_id FROM gitlab_configs ORDER BY workspace_id`) + return ids, err +} + +// UpdateConfigHealthForRevision writes a probe result only when the connection +// identity is unchanged from the revision used to construct the client. +func (s *Store) UpdateConfigHealthForRevision(ctx context.Context, workspaceID, username string, ok bool, errMsg string, checkedAt time.Time, revision int64) (bool, error) { + return updateConfigHealthForRevision(ctx, s.db, workspaceID, username, ok, errMsg, checkedAt, revision) +} + +func updateConfigHealthForRevision(ctx context.Context, execer configExecer, workspaceID, username string, ok bool, errMsg string, checkedAt time.Time, revision int64) (bool, error) { + result, err := execer.ExecContext(ctx, ` + UPDATE gitlab_configs + SET username = ?, last_ok = ?, last_error = ?, last_checked_at = ?, updated_at = ? + WHERE workspace_id = ? AND revision = ?`, + username, ok, errMsg, checkedAt, checkedAt, workspaceID, revision) + if err != nil { + return false, err + } + rows, err := result.RowsAffected() + return rows > 0, err +} + +// RestoreConfigForWorkspace restores an exact metadata snapshot after an +// external secret-store operation fails. It is only used as compensation. +func (s *Store) RestoreConfigForWorkspace(ctx context.Context, workspaceID string, cfg *GitLabConfig) error { + if cfg == nil { + return s.DeleteConfigForWorkspace(ctx, workspaceID) + } + _, err := s.db.ExecContext(ctx, ` + INSERT INTO gitlab_configs ( + workspace_id, host, auth_method, username, last_ok, last_error, + last_checked_at, revision, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(workspace_id) DO UPDATE SET + host = excluded.host, + auth_method = excluded.auth_method, + username = excluded.username, + last_ok = excluded.last_ok, + last_error = excluded.last_error, + last_checked_at = excluded.last_checked_at, + revision = excluded.revision, + created_at = excluded.created_at, + updated_at = excluded.updated_at`, + workspaceID, cfg.Host, cfg.AuthMethod, cfg.Username, cfg.LastOK, cfg.LastError, + cfg.LastCheckedAt, cfg.Revision, cfg.CreatedAt, cfg.UpdatedAt) + return err +} + +// WorkspaceIDForTask resolves the authoritative workspace for a task-backed +// operation. +func (s *Store) WorkspaceIDForTask(ctx context.Context, taskID string) (string, error) { + var workspaceID string + err := s.ro.GetContext(ctx, &workspaceID, `SELECT workspace_id FROM tasks WHERE id = ?`, taskID) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotConfigured + } + return workspaceID, err +} diff --git a/apps/backend/internal/gitlab/store_config_test.go b/apps/backend/internal/gitlab/store_config_test.go new file mode 100644 index 0000000000..9601a7e3fd --- /dev/null +++ b/apps/backend/internal/gitlab/store_config_test.go @@ -0,0 +1,141 @@ +package gitlab + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db" +) + +func TestStoreCreatesWorkspaceScopedConfigTable(t *testing.T) { + store := newTestStore(t) + + var createSQL string + if err := store.ro.Get(&createSQL, ` + SELECT sql FROM sqlite_master + WHERE type = 'table' AND name = 'gitlab_configs' + `); err != nil { + t.Fatalf("read gitlab_configs schema: %v", err) + } + if createSQL == "" { + t.Fatal("gitlab_configs schema is empty") + } +} + +func TestStoreConfigIsolatesWorkspacesAndPreservesScheme(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + seedWorkspace(t, store, "workspace-http") + seedWorkspace(t, store, "workspace-https") + + if err := store.UpsertConfigForWorkspace(ctx, "workspace-http", &GitLabConfig{ + Host: "http://gitlab.internal", + AuthMethod: AuthMethodPAT, + }); err != nil { + t.Fatalf("upsert HTTP config: %v", err) + } + if err := store.UpsertConfigForWorkspace(ctx, "workspace-https", &GitLabConfig{ + Host: "https://gitlab.example.com", + AuthMethod: AuthMethodGLab, + }); err != nil { + t.Fatalf("upsert HTTPS config: %v", err) + } + + httpConfig, err := store.GetConfigForWorkspace(ctx, "workspace-http") + if err != nil { + t.Fatalf("get HTTP config: %v", err) + } + httpsConfig, err := store.GetConfigForWorkspace(ctx, "workspace-https") + if err != nil { + t.Fatalf("get HTTPS config: %v", err) + } + if httpConfig.Host != "http://gitlab.internal" { + t.Fatalf("HTTP host = %q", httpConfig.Host) + } + if httpsConfig.Host != "https://gitlab.example.com" { + t.Fatalf("HTTPS host = %q", httpsConfig.Host) + } +} + +func TestStoreConfigRevisionChangesOnlyWithConnectionIdentity(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + seedWorkspace(t, store, "workspace-a") + if err := store.UpsertConfigForWorkspace(ctx, "workspace-a", &GitLabConfig{ + Host: "https://old.gitlab.example", AuthMethod: AuthMethodPAT, + }); err != nil { + t.Fatalf("seed config: %v", err) + } + if got := configRevision(t, store, "workspace-a"); got != 1 { + t.Fatalf("initial revision = %d, want 1", got) + } + updated, err := store.UpdateConfigHealthForRevision( + ctx, "workspace-a", "alice", true, "", time.Now().UTC(), 1, + ) + if err != nil { + t.Fatalf("update health: %v", err) + } + if !updated { + t.Fatal("health update did not match initial revision") + } + if got := configRevision(t, store, "workspace-a"); got != 1 { + t.Fatalf("revision after health update = %d, want 1", got) + } + if err := store.UpsertConfigForWorkspace(ctx, "workspace-a", &GitLabConfig{ + Host: "https://new.gitlab.example", AuthMethod: AuthMethodPAT, + }); err != nil { + t.Fatalf("replace config: %v", err) + } + if got := configRevision(t, store, "workspace-a"); got != 2 { + t.Fatalf("revision after config replacement = %d, want 2", got) + } +} + +func TestStoreMigratesLegacyGitLabConfigRevision(t *testing.T) { + dbConn, err := db.OpenSQLite(filepath.Join(t.TempDir(), "legacy-gitlab.db")) + if err != nil { + t.Fatalf("open legacy db: %v", err) + } + sqlDB := sqlx.NewDb(dbConn, "sqlite3") + t.Cleanup(func() { _ = sqlDB.Close() }) + if _, err := sqlDB.Exec(` + CREATE TABLE workspaces (id TEXT PRIMARY KEY, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP); + CREATE TABLE gitlab_configs ( + workspace_id TEXT PRIMARY KEY, + host TEXT NOT NULL, + auth_method TEXT NOT NULL, + username TEXT NOT NULL DEFAULT '', + last_ok INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + last_checked_at DATETIME, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + INSERT INTO workspaces (id) VALUES ('workspace-a'); + INSERT INTO gitlab_configs ( + workspace_id, host, auth_method, created_at, updated_at + ) VALUES ('workspace-a', 'https://gitlab.example', 'pat', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + `); err != nil { + t.Fatalf("seed legacy schema: %v", err) + } + store, err := NewStore(sqlDB, sqlDB) + if err != nil { + t.Fatalf("migrate legacy store: %v", err) + } + if got := configRevision(t, store, "workspace-a"); got != 1 { + t.Fatalf("migrated revision = %d, want 1", got) + } +} + +func configRevision(t *testing.T, store *Store, workspaceID string) int64 { + t.Helper() + var revision int64 + if err := store.ro.Get(&revision, `SELECT revision FROM gitlab_configs WHERE workspace_id = ?`, workspaceID); err != nil { + t.Fatalf("read config revision: %v", err) + } + return revision +} diff --git a/apps/backend/internal/gitlab/store_e2e_reset.go b/apps/backend/internal/gitlab/store_e2e_reset.go new file mode 100644 index 0000000000..e989539aca --- /dev/null +++ b/apps/backend/internal/gitlab/store_e2e_reset.go @@ -0,0 +1,94 @@ +package gitlab + +import "context" + +// E2EResetResult summarizes the workspace-scoped poller definitions removed +// before the shared E2E task reset starts deleting tasks. +type E2EResetResult struct { + ReviewWatches int `json:"review_watches"` + IssueWatches int `json:"issue_watches"` +} + +// ResetWorkspaceE2E removes all workspace-owned GitLab state except the +// connection. The service deletes the connection separately so its secret can +// be removed through the credential store instead of bypassing it with SQL. +func (s *Store) ResetWorkspaceE2E(ctx context.Context, workspaceID string) (E2EResetResult, error) { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return E2EResetResult{}, err + } + defer func() { _ = tx.Rollback() }() + + queries := []string{ + `DELETE FROM gitlab_review_mr_tasks WHERE review_watch_id IN + (SELECT id FROM gitlab_review_watches WHERE workspace_id = ?)`, + `DELETE FROM gitlab_issue_watch_tasks WHERE issue_watch_id IN + (SELECT id FROM gitlab_issue_watches WHERE workspace_id = ?)`, + } + for _, query := range queries { + if _, err := tx.ExecContext(ctx, query, workspaceID); err != nil { + return E2EResetResult{}, err + } + } + + reviewResult, err := tx.ExecContext(ctx, + `DELETE FROM gitlab_review_watches WHERE workspace_id = ?`, workspaceID) + if err != nil { + return E2EResetResult{}, err + } + issueResult, err := tx.ExecContext(ctx, + `DELETE FROM gitlab_issue_watches WHERE workspace_id = ?`, workspaceID) + if err != nil { + return E2EResetResult{}, err + } + + workspaceDeletes := []string{ + `DELETE FROM gitlab_action_presets WHERE workspace_id = ?`, + `DELETE FROM gitlab_mr_watches WHERE task_id IN + (SELECT id FROM tasks WHERE workspace_id = ?)`, + `DELETE FROM gitlab_task_mrs WHERE task_id IN + (SELECT id FROM tasks WHERE workspace_id = ?)`, + } + for _, query := range workspaceDeletes { + if _, err := tx.ExecContext(ctx, query, workspaceID); err != nil { + return E2EResetResult{}, err + } + } + + reviewCount, err := reviewResult.RowsAffected() + if err != nil { + return E2EResetResult{}, err + } + issueCount, err := issueResult.RowsAffected() + if err != nil { + return E2EResetResult{}, err + } + if err := tx.Commit(); err != nil { + return E2EResetResult{}, err + } + return E2EResetResult{ReviewWatches: int(reviewCount), IssueWatches: int(issueCount)}, nil +} + +// ResetWorkspaceE2E clears provider mock data, poller state, durable links, +// presets, and the workspace connection. It is called only by the backend's +// test-gated E2E reset route. +func (s *Service) ResetWorkspaceE2E(ctx context.Context, workspaceID string) (E2EResetResult, error) { + s.mu.RLock() + store := s.store + cachedClient := s.workspaceClients[workspaceID] + s.mu.RUnlock() + if store == nil { + return E2EResetResult{}, ErrNotConfigured + } + result, err := store.ResetWorkspaceE2E(ctx, workspaceID) + if err != nil { + return E2EResetResult{}, err + } + if mock, ok := cachedClient.(*MockClient); ok { + mock.Reset() + } + if err := s.DeleteConfigForWorkspace(ctx, workspaceID); err != nil { + return result, err + } + return result, nil +} diff --git a/apps/backend/internal/gitlab/store_e2e_reset_test.go b/apps/backend/internal/gitlab/store_e2e_reset_test.go new file mode 100644 index 0000000000..3077d590de --- /dev/null +++ b/apps/backend/internal/gitlab/store_e2e_reset_test.go @@ -0,0 +1,122 @@ +package gitlab + +import "testing" + +func TestStoreResetWorkspaceE2EIsScopedAndClearsAutomationState(t *testing.T) { + store := newTestStore(t) + for _, workspaceID := range []string{"ws-a", "ws-b"} { + seedWorkspace(t, store, workspaceID) + seedTask(t, store, "task-"+workspaceID, workspaceID) + if err := store.UpsertConfigForWorkspace(t.Context(), workspaceID, &GitLabConfig{ + Host: "https://" + workspaceID + ".example.com", AuthMethod: AuthMethodPAT, + }); err != nil { + t.Fatalf("seed config %s: %v", workspaceID, err) + } + } + + reviewA := &ReviewWatch{WorkspaceID: "ws-a", WorkflowID: "wf", WorkflowStepID: "step", AgentProfileID: "agent", ExecutorProfileID: "exec", Enabled: true} + reviewB := &ReviewWatch{WorkspaceID: "ws-b", WorkflowID: "wf", WorkflowStepID: "step", AgentProfileID: "agent", ExecutorProfileID: "exec", Enabled: true} + issueA := &IssueWatch{WorkspaceID: "ws-a", WorkflowID: "wf", WorkflowStepID: "step", AgentProfileID: "agent", ExecutorProfileID: "exec", Enabled: true} + issueB := &IssueWatch{WorkspaceID: "ws-b", WorkflowID: "wf", WorkflowStepID: "step", AgentProfileID: "agent", ExecutorProfileID: "exec", Enabled: true} + for _, watch := range []*ReviewWatch{reviewA, reviewB} { + if err := store.CreateReviewWatch(t.Context(), watch); err != nil { + t.Fatal(err) + } + if _, err := store.ReserveReviewMRTask(t.Context(), watch.ID, "group/project", 7, "url"); err != nil { + t.Fatal(err) + } + } + for _, watch := range []*IssueWatch{issueA, issueB} { + if err := store.CreateIssueWatch(t.Context(), watch); err != nil { + t.Fatal(err) + } + if _, err := store.ReserveIssueWatchTask(t.Context(), watch.ID, "group/project", 8, "url"); err != nil { + t.Fatal(err) + } + } + for _, workspaceID := range []string{"ws-a", "ws-b"} { + if err := store.UpsertActionPresets(t.Context(), &ActionPresets{ + WorkspaceID: workspaceID, + MR: []ActionPreset{{ID: "preset", Label: "Preset"}}, + }); err != nil { + t.Fatal(err) + } + if err := store.UpsertTaskMR(t.Context(), &TaskMR{ + TaskID: "task-" + workspaceID, ProjectPath: "group/project", MRIID: 7, MRURL: "url", + }); err != nil { + t.Fatal(err) + } + if err := store.CreateMRWatch(t.Context(), &MRWatch{ + SessionID: "session-" + workspaceID, TaskID: "task-" + workspaceID, + ProjectPath: "group/project", MRIID: 7, + }); err != nil { + t.Fatal(err) + } + } + + result, err := store.ResetWorkspaceE2E(t.Context(), "ws-a") + if err != nil { + t.Fatalf("reset workspace: %v", err) + } + if result.ReviewWatches != 1 || result.IssueWatches != 1 { + t.Fatalf("reset result = %+v", result) + } + assertWorkspaceGitLabRows(t, store, "ws-a", 0) + assertWorkspaceGitLabRows(t, store, "ws-b", 1) + if cfg, err := store.GetConfigForWorkspace(t.Context(), "ws-a"); err != nil || cfg == nil { + t.Fatalf("store reset should leave config for credential-aware service cleanup: cfg=%#v err=%v", cfg, err) + } +} + +func TestServiceResetWorkspaceE2EAlsoClearsCredentialAndMockClient(t *testing.T) { + store := newTestStore(t) + seedWorkspace(t, store, "ws-a") + if err := store.UpsertConfigForWorkspace(t.Context(), "ws-a", &GitLabConfig{ + Host: "https://gitlab-a.example.com", AuthMethod: AuthMethodPAT, + }); err != nil { + t.Fatal(err) + } + secrets := &configTestSecrets{values: map[string]string{SecretKeyForWorkspace("ws-a"): "token"}} + mock := NewMockClient("https://gitlab-a.example.com") + mock.SeedMR("group/project", &MR{IID: 7}) + service := NewService(DefaultHost, NewMockClient(DefaultHost), "mock", nil, newTestLogger(t)) + service.SetStore(store) + service.SetWorkspaceSecretStore(secrets) + service.workspaceClients["ws-a"] = mock + + if _, err := service.ResetWorkspaceE2E(t.Context(), "ws-a"); err != nil { + t.Fatalf("service reset: %v", err) + } + if cfg, err := store.GetConfigForWorkspace(t.Context(), "ws-a"); err != nil || cfg != nil { + t.Fatalf("config after reset = %#v, err=%v", cfg, err) + } + if _, ok := secrets.values[SecretKeyForWorkspace("ws-a")]; ok { + t.Fatal("workspace token survived reset") + } + if got := mock.Stats(); got != "mrs=0 discussions=0 issues=0" { + t.Fatalf("mock after reset = %q", got) + } +} + +func assertWorkspaceGitLabRows(t *testing.T, store *Store, workspaceID string, want int) { + t.Helper() + checks := []struct { + query string + args []any + }{ + {`SELECT COUNT(*) FROM gitlab_review_watches WHERE workspace_id = ?`, []any{workspaceID}}, + {`SELECT COUNT(*) FROM gitlab_issue_watches WHERE workspace_id = ?`, []any{workspaceID}}, + {`SELECT COUNT(*) FROM gitlab_action_presets WHERE workspace_id = ?`, []any{workspaceID}}, + {`SELECT COUNT(*) FROM gitlab_task_mrs WHERE task_id = ?`, []any{"task-" + workspaceID}}, + {`SELECT COUNT(*) FROM gitlab_mr_watches WHERE task_id = ?`, []any{"task-" + workspaceID}}, + } + for _, check := range checks { + var got int + if err := store.ro.Get(&got, check.query, check.args...); err != nil { + t.Fatalf("count %q: %v", check.query, err) + } + if got != want { + t.Fatalf("count %q for %s = %d, want %d", check.query, workspaceID, got, want) + } + } +} diff --git a/apps/backend/internal/gitlab/store_task_mr_link.go b/apps/backend/internal/gitlab/store_task_mr_link.go new file mode 100644 index 0000000000..47844b4003 --- /dev/null +++ b/apps/backend/internal/gitlab/store_task_mr_link.go @@ -0,0 +1,159 @@ +package gitlab + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" +) + +// ValidateTaskMRScope verifies that the task and optional repository belong to +// the requested workspace. Unknown and cross-workspace resources deliberately +// share one error so callers do not disclose resource existence. +func (s *Store) ValidateTaskMRScope(ctx context.Context, workspaceID, taskID, repositoryID string) error { + var taskExists int + err := s.ro.GetContext(ctx, &taskExists, + `SELECT 1 FROM tasks WHERE id = ? AND workspace_id = ? LIMIT 1`, taskID, workspaceID) + if errors.Is(err, sql.ErrNoRows) { + return ErrTaskMRNotFound + } + if err != nil { + return fmt.Errorf("validate task workspace: %w", err) + } + if repositoryID == "" { + return nil + } + var repositoryExists int + err = s.ro.GetContext(ctx, &repositoryExists, ` + SELECT 1 + FROM task_repositories tr + JOIN repositories r ON r.id = tr.repository_id + WHERE tr.task_id = ? AND tr.repository_id = ? AND r.workspace_id = ? + LIMIT 1`, taskID, repositoryID, workspaceID) + if errors.Is(err, sql.ErrNoRows) { + return ErrTaskMRNotFound + } + if err != nil { + return fmt.Errorf("validate task repository: %w", err) + } + return nil +} + +// ValidateTaskMRRepositoryIdentity verifies the durable provider identity for +// the repository selected by the task. Legacy rows without a provider host +// fail closed because owner/name alone cannot distinguish GitLab instances. +func (s *Store) ValidateTaskMRRepositoryIdentity( + ctx context.Context, + workspaceID, taskID, repositoryID, configuredHost, projectPath string, +) error { + if repositoryID == "" { + return ErrTaskMRRepositoryMismatch + } + var identity struct { + Provider string `db:"provider"` + Host string `db:"provider_host"` + Owner string `db:"provider_owner"` + Name string `db:"provider_name"` + } + err := s.ro.GetContext(ctx, &identity, ` + SELECT r.provider, r.provider_host, r.provider_owner, r.provider_name + FROM task_repositories tr + JOIN repositories r ON r.id = tr.repository_id + JOIN tasks t ON t.id = tr.task_id + WHERE tr.task_id = ? AND tr.repository_id = ? + AND t.workspace_id = ? AND r.workspace_id = ? + LIMIT 1`, taskID, repositoryID, workspaceID, workspaceID) + if errors.Is(err, sql.ErrNoRows) { + return ErrTaskMRNotFound + } + if err != nil { + return fmt.Errorf("load task repository identity: %w", err) + } + wantHost, err := normalizeHostOrigin(configuredHost) + if err != nil { + return ErrTaskMRRepositoryMismatch + } + gotHost, err := normalizeHostOrigin(identity.Host) + if err != nil { + return ErrTaskMRRepositoryMismatch + } + gotProject := strings.Trim(strings.TrimSpace(identity.Owner+"/"+identity.Name), "/") + wantProject := strings.Trim(strings.TrimSpace(projectPath), "/") + if !strings.EqualFold(identity.Provider, "gitlab") || + !strings.EqualFold(gotHost, wantHost) || + !strings.EqualFold(gotProject, wantProject) { + return ErrTaskMRRepositoryMismatch + } + return nil +} + +// ResolveTaskMRRepository validates an explicit repository or infers the sole +// task repository. Scratch tasks retain an empty repository association; +// multi-repository tasks must make the choice explicit. +func (s *Store) ResolveTaskMRRepository( + ctx context.Context, + workspaceID, taskID, repositoryID string, +) (string, error) { + if repositoryID != "" { + if err := s.ValidateTaskMRScope(ctx, workspaceID, taskID, repositoryID); err != nil { + return "", err + } + return repositoryID, nil + } + if err := s.ValidateTaskMRScope(ctx, workspaceID, taskID, ""); err != nil { + return "", err + } + var repositoryIDs []string + if err := s.ro.SelectContext(ctx, &repositoryIDs, ` + SELECT tr.repository_id + FROM task_repositories tr + JOIN repositories r ON r.id = tr.repository_id + WHERE tr.task_id = ? AND r.workspace_id = ? + ORDER BY tr.id`, taskID, workspaceID); err != nil { + return "", fmt.Errorf("list task repositories: %w", err) + } + switch len(repositoryIDs) { + case 0: + return "", nil + case 1: + return repositoryIDs[0], nil + default: + return "", ErrTaskMRRepositoryRequired + } +} + +// DeleteTaskMRForWorkspace atomically removes one association and only the +// refresh watch that identifies the same task, repository, project and MR. +func (s *Store) DeleteTaskMRForWorkspace(ctx context.Context, workspaceID, associationID string) error { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + var association TaskMR + err = tx.GetContext(ctx, &association, ` + SELECT `+taskMRSelectColsQualified+` + FROM gitlab_task_mrs gtm + JOIN tasks t ON t.id = gtm.task_id + WHERE gtm.id = ? AND t.workspace_id = ? + LIMIT 1`, associationID, workspaceID) + if errors.Is(err, sql.ErrNoRows) { + return ErrTaskMRNotFound + } + if err != nil { + return fmt.Errorf("find task MR association: %w", err) + } + if _, err = tx.ExecContext(ctx, ` + DELETE FROM gitlab_mr_watches + WHERE task_id = ? AND repository_id = ? AND project_path = ? AND mr_iid = ?`, + association.TaskID, association.RepositoryID, association.ProjectPath, association.MRIID, + ); err != nil { + return fmt.Errorf("delete task MR refresh watch: %w", err) + } + if _, err = tx.ExecContext(ctx, `DELETE FROM gitlab_task_mrs WHERE id = ?`, association.ID); err != nil { + return fmt.Errorf("delete task MR association: %w", err) + } + return tx.Commit() +} diff --git a/apps/backend/internal/gitlab/store_test.go b/apps/backend/internal/gitlab/store_test.go index fd7d9073f8..cba9f5172c 100644 --- a/apps/backend/internal/gitlab/store_test.go +++ b/apps/backend/internal/gitlab/store_test.go @@ -2,7 +2,9 @@ package gitlab import ( "context" + "fmt" "path/filepath" + "sync" "testing" "time" @@ -20,7 +22,12 @@ func newTestStore(t *testing.T) *Store { t.Fatalf("open db: %v", err) } sqlxDB := sqlx.NewDb(dbConn, "sqlite3") - if _, err := sqlxDB.Exec(`CREATE TABLE tasks ( + t.Cleanup(func() { _ = sqlxDB.Close() }) + if _, err := sqlxDB.Exec(`CREATE TABLE workspaces ( + id TEXT PRIMARY KEY, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE tasks ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL DEFAULT '', archived_at DATETIME @@ -34,6 +41,13 @@ func newTestStore(t *testing.T) *Store { return store } +func seedWorkspace(t *testing.T, store *Store, workspaceID string) { + t.Helper() + if _, err := store.db.Exec(`INSERT INTO workspaces (id) VALUES (?)`, workspaceID); err != nil { + t.Fatalf("seed workspace %s: %v", workspaceID, err) + } +} + func seedTask(t *testing.T, store *Store, taskID, workspaceID string) { t.Helper() if _, err := store.db.Exec( @@ -103,6 +117,55 @@ func TestStore_UpsertTaskMR_InsertsThenUpdates(t *testing.T) { } } +func TestStore_UpsertTaskMR_ConcurrentCallsPreserveOriginalIdentity(t *testing.T) { + store := newTestStore(t) + const writers = 16 + start := make(chan struct{}) + results := make(chan *TaskMR, writers) + errs := make(chan error, writers) + var wg sync.WaitGroup + + for i := 0; i < writers; i++ { + wg.Add(1) + go func(index int) { + defer wg.Done() + <-start + row := newTestMR("task-concurrent", "repo-1", "group/subgroup/project", 7) + row.ID = fmt.Sprintf("candidate-%02d", index) + row.CreatedAt = time.Date(2026, 1, 1, 0, 0, index, 0, time.UTC) + row.MRTitle = fmt.Sprintf("writer-%02d", index) + errs <- store.UpsertTaskMR(context.Background(), row) + results <- row + }(i) + } + close(start) + wg.Wait() + close(errs) + close(results) + + for err := range errs { + if err != nil { + t.Fatalf("concurrent upsert: %v", err) + } + } + rows, err := store.ListTaskMRsByTask(context.Background(), "task-concurrent") + if err != nil { + t.Fatalf("list task MRs: %v", err) + } + if len(rows) != 1 { + t.Fatalf("stored rows = %d, want exactly one", len(rows)) + } + stored := rows[0] + for result := range results { + if result.ID != stored.ID { + t.Fatalf("caller ID = %q, stored ID = %q", result.ID, stored.ID) + } + if !result.CreatedAt.Equal(stored.CreatedAt) { + t.Fatalf("caller created_at = %v, stored created_at = %v", result.CreatedAt, stored.CreatedAt) + } + } +} + func TestStore_UpsertTaskMR_KeyedByRepoIDAndProjectAndIID(t *testing.T) { store := newTestStore(t) ctx := context.Background() diff --git a/apps/backend/internal/gitlab/store_watch_reset.go b/apps/backend/internal/gitlab/store_watch_reset.go new file mode 100644 index 0000000000..ab3c8ff2f8 --- /dev/null +++ b/apps/backend/internal/gitlab/store_watch_reset.go @@ -0,0 +1,184 @@ +package gitlab + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/jmoiron/sqlx" +) + +type watchInvalidation struct { + Generation int64 + TaskIDs []string + Missing bool +} + +func (s *Store) BeginReviewWatchReset(ctx context.Context, watchID string) (*watchInvalidation, error) { + return s.beginWatchInvalidation(ctx, "gitlab_review_watches", "gitlab_review_mr_tasks", "review_watch_id", watchID, false) +} + +func (s *Store) BeginIssueWatchReset(ctx context.Context, watchID string) (*watchInvalidation, error) { + return s.beginWatchInvalidation(ctx, "gitlab_issue_watches", "gitlab_issue_watch_tasks", "issue_watch_id", watchID, false) +} + +func (s *Store) BeginReviewWatchDelete(ctx context.Context, watchID string) (*watchInvalidation, error) { + return s.beginWatchInvalidation(ctx, "gitlab_review_watches", "gitlab_review_mr_tasks", "review_watch_id", watchID, true) +} + +func (s *Store) BeginIssueWatchDelete(ctx context.Context, watchID string) (*watchInvalidation, error) { + return s.beginWatchInvalidation(ctx, "gitlab_issue_watches", "gitlab_issue_watch_tasks", "issue_watch_id", watchID, true) +} + +func (s *Store) beginWatchInvalidation(ctx context.Context, watchTable, taskTable, foreignKey, watchID string, deleting bool) (*watchInvalidation, error) { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + set := "generation = generation + 1, updated_at = CURRENT_TIMESTAMP" + if deleting { + set += ", deleting = 1, enabled = 0" + } + result, err := tx.ExecContext(ctx, + fmt.Sprintf("UPDATE %s SET %s WHERE id = ? AND deleting = 0", watchTable, set), watchID) + if err != nil { + return nil, err + } + rows, err := result.RowsAffected() + if err != nil { + return nil, err + } + if rows == 0 { + if !deleting { + return nil, ErrWatchNotFound + } + return s.resumeWatchDelete(ctx, tx, watchTable, taskTable, foreignKey, watchID) + } + var generation int64 + if err := tx.GetContext(ctx, &generation, + fmt.Sprintf("SELECT generation FROM %s WHERE id = ?", watchTable), watchID); err != nil { + return nil, err + } + var taskIDs []string + if err := tx.SelectContext(ctx, &taskIDs, + fmt.Sprintf("SELECT task_id FROM %s WHERE %s = ? AND generation < ? AND task_id <> '' ORDER BY created_at", taskTable, foreignKey), + watchID, generation); err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return &watchInvalidation{Generation: generation, TaskIDs: taskIDs}, nil +} + +func (s *Store) resumeWatchDelete(ctx context.Context, tx *sqlx.Tx, watchTable, taskTable, foreignKey, watchID string) (*watchInvalidation, error) { + var state struct { + Generation int64 `db:"generation"` + Deleting bool `db:"deleting"` + } + err := tx.GetContext(ctx, &state, + fmt.Sprintf("SELECT generation, deleting FROM %s WHERE id = ?", watchTable), watchID) + if errors.Is(err, sql.ErrNoRows) { + return &watchInvalidation{Missing: true}, nil + } + if err != nil { + return nil, err + } + if !state.Deleting { + return nil, ErrWatchOwnershipLost + } + var taskIDs []string + if err := tx.SelectContext(ctx, &taskIDs, + fmt.Sprintf("SELECT task_id FROM %s WHERE %s = ? AND generation < ? AND task_id <> '' ORDER BY created_at", taskTable, foreignKey), + watchID, state.Generation); err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return &watchInvalidation{Generation: state.Generation, TaskIDs: taskIDs}, nil +} + +func (s *Store) FinishReviewWatchReset(ctx context.Context, watchID string, generation int64) error { + return s.finishWatchReset(ctx, "gitlab_review_watches", "gitlab_review_mr_tasks", "review_watch_id", watchID, generation) +} + +func (s *Store) FinishIssueWatchReset(ctx context.Context, watchID string, generation int64) error { + return s.finishWatchReset(ctx, "gitlab_issue_watches", "gitlab_issue_watch_tasks", "issue_watch_id", watchID, generation) +} + +func (s *Store) finishWatchReset(ctx context.Context, watchTable, taskTable, foreignKey, watchID string, generation int64) error { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, + fmt.Sprintf("DELETE FROM %s WHERE %s = ? AND generation < ?", taskTable, foreignKey), watchID, generation); err != nil { + return err + } + result, err := tx.ExecContext(ctx, + fmt.Sprintf("UPDATE %s SET last_polled_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND generation = ? AND deleting = 0", watchTable), + watchID, generation) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return ErrWatchOwnershipLost + } + return tx.Commit() +} + +func (s *Store) ListReviewMRTaskIDsByWatch(ctx context.Context, watchID string) ([]string, error) { + var ids []string + if err := s.ro.SelectContext(ctx, &ids, + `SELECT task_id FROM gitlab_review_mr_tasks WHERE review_watch_id = ? ORDER BY created_at`, watchID); err != nil { + return nil, err + } + return ids, nil +} + +func (s *Store) ResetReviewWatchState(ctx context.Context, watchID string) error { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, `DELETE FROM gitlab_review_mr_tasks WHERE review_watch_id = ?`, watchID); err != nil { + return fmt.Errorf("clear review watch tasks: %w", err) + } + if _, err := tx.ExecContext(ctx, `UPDATE gitlab_review_watches SET last_polled_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, watchID); err != nil { + return fmt.Errorf("clear review watch poll state: %w", err) + } + return tx.Commit() +} + +func (s *Store) ListIssueWatchTaskIDsByWatch(ctx context.Context, watchID string) ([]string, error) { + var ids []string + if err := s.ro.SelectContext(ctx, &ids, + `SELECT task_id FROM gitlab_issue_watch_tasks WHERE issue_watch_id = ? ORDER BY created_at`, watchID); err != nil { + return nil, err + } + return ids, nil +} + +func (s *Store) ResetIssueWatchState(ctx context.Context, watchID string) error { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, `DELETE FROM gitlab_issue_watch_tasks WHERE issue_watch_id = ?`, watchID); err != nil { + return fmt.Errorf("clear issue watch tasks: %w", err) + } + if _, err := tx.ExecContext(ctx, `UPDATE gitlab_issue_watches SET last_polled_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, watchID); err != nil { + return fmt.Errorf("clear issue watch poll state: %w", err) + } + return tx.Commit() +} diff --git a/apps/backend/internal/gitlab/store_watch_selfheal_test.go b/apps/backend/internal/gitlab/store_watch_selfheal_test.go new file mode 100644 index 0000000000..c3ee560871 --- /dev/null +++ b/apps/backend/internal/gitlab/store_watch_selfheal_test.go @@ -0,0 +1,39 @@ +package gitlab + +import ( + "context" + "testing" +) + +func TestStoreDisableGitLabWatchesWithError(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + review := &ReviewWatch{ + WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step", + AgentProfileID: "agent", ExecutorProfileID: "executor", Enabled: true, + } + issue := &IssueWatch{ + WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step", + AgentProfileID: "agent", ExecutorProfileID: "executor", Enabled: true, + } + if err := store.CreateReviewWatch(ctx, review); err != nil { + t.Fatalf("create review: %v", err) + } + if err := store.CreateIssueWatch(ctx, issue); err != nil { + t.Fatalf("create issue: %v", err) + } + if err := store.DisableReviewWatchWithError(ctx, review.ID, "profile removed"); err != nil { + t.Fatalf("disable review: %v", err) + } + if err := store.DisableIssueWatchWithError(ctx, issue.ID, "repository removed"); err != nil { + t.Fatalf("disable issue: %v", err) + } + gotReview, _ := store.GetReviewWatch(ctx, review.ID) + gotIssue, _ := store.GetIssueWatch(ctx, issue.ID) + if gotReview.Enabled || gotReview.LastError != "profile removed" || gotReview.LastErrorAt == nil { + t.Fatalf("review self-heal state = %#v", gotReview) + } + if gotIssue.Enabled || gotIssue.LastError != "repository removed" || gotIssue.LastErrorAt == nil { + t.Fatalf("issue self-heal state = %#v", gotIssue) + } +} diff --git a/apps/backend/internal/gitlab/store_watches.go b/apps/backend/internal/gitlab/store_watches.go index 49cad9f1fe..8a5f5ce2ee 100644 --- a/apps/backend/internal/gitlab/store_watches.go +++ b/apps/backend/internal/gitlab/store_watches.go @@ -76,6 +76,13 @@ func (s *Store) ListMRWatchesBySession(ctx context.Context, sessionID string) ([ return out, nil } +// ListMRWatchesBySessionForWorkspace scopes a session lookup through the +// owning task. Legacy rows whose task no longer exists are intentionally not +// exposed by workspace-facing routes. +func (s *Store) ListMRWatchesBySessionForWorkspace(ctx context.Context, workspaceID, sessionID string) ([]*MRWatch, error) { + return s.listMRWatchesForWorkspace(ctx, workspaceID, "w.session_id = ?", sessionID) +} + // ListMRWatchesByTask returns every MR watch for a task. func (s *Store) ListMRWatchesByTask(ctx context.Context, taskID string) ([]*MRWatch, error) { var ws []MRWatch @@ -91,6 +98,10 @@ func (s *Store) ListMRWatchesByTask(ctx context.Context, taskID string) ([]*MRWa return out, nil } +func (s *Store) ListMRWatchesByTaskForWorkspace(ctx context.Context, workspaceID, taskID string) ([]*MRWatch, error) { + return s.listMRWatchesForWorkspace(ctx, workspaceID, "w.task_id = ?", taskID) +} + // ListActiveMRWatches returns every persisted MR watch (used by the poller). func (s *Store) ListActiveMRWatches(ctx context.Context) ([]*MRWatch, error) { var ws []MRWatch @@ -105,6 +116,30 @@ func (s *Store) ListActiveMRWatches(ctx context.Context) ([]*MRWatch, error) { return out, nil } +func (s *Store) ListActiveMRWatchesForWorkspace(ctx context.Context, workspaceID string) ([]*MRWatch, error) { + return s.listMRWatchesForWorkspace(ctx, workspaceID, "1 = 1") +} + +// predicate must be a package-owned SQL literal; never pass request data. +func (s *Store) listMRWatchesForWorkspace(ctx context.Context, workspaceID, predicate string, args ...interface{}) ([]*MRWatch, error) { + queryArgs := append([]interface{}{workspaceID}, args...) + var rows []MRWatch + if err := s.ro.SelectContext(ctx, &rows, + `SELECT w.id, w.session_id, w.task_id, w.repository_id, w.project_path, + w.mr_iid, w.branch, w.last_checked_at, w.last_note_at, + w.last_pipeline_state, w.last_approval_state, w.created_at, w.updated_at + FROM gitlab_mr_watches w + JOIN tasks t ON t.id = w.task_id + WHERE t.workspace_id = ? AND `+predicate+` ORDER BY w.created_at ASC`, queryArgs...); err != nil { + return nil, err + } + out := make([]*MRWatch, 0, len(rows)) + for i := range rows { + out = append(out, &rows[i]) + } + return out, nil +} + // UpdateMRWatchTimestamps records the last poll cycle's observation. func (s *Store) UpdateMRWatchTimestamps(ctx context.Context, id string, checkedAt time.Time, noteAt *time.Time, pipelineState, approvalState string) error { _, err := s.db.ExecContext(ctx, ` @@ -129,6 +164,18 @@ func (s *Store) DeleteMRWatch(ctx context.Context, id string) error { return err } +func (s *Store) DeleteMRWatchForWorkspace(ctx context.Context, workspaceID, id string) (bool, error) { + result, err := s.db.ExecContext(ctx, `DELETE FROM gitlab_mr_watches + WHERE id = ? AND EXISTS ( + SELECT 1 FROM tasks t WHERE t.id = gitlab_mr_watches.task_id AND t.workspace_id = ? + )`, id, workspaceID) + if err != nil { + return false, err + } + count, err := result.RowsAffected() + return count > 0, err +} + // DeleteMRWatchesByTaskID removes all MR watches associated with a task. func (s *Store) DeleteMRWatchesByTaskID(ctx context.Context, taskID string) (int64, error) { res, err := s.db.ExecContext(ctx, @@ -142,9 +189,9 @@ func (s *Store) DeleteMRWatchesByTaskID(ctx context.Context, taskID string) (int // --- Review Watch CRUD --- const reviewWatchSelectCols = `id, workspace_id, workflow_id, workflow_step_id, - projects, agent_profile_id, executor_profile_id, prompt, review_scope, + projects, agent_profile_id, executor_profile_id, prompt, repository_id, base_branch, review_scope, custom_query, enabled, poll_interval_seconds, cleanup_policy, - last_polled_at, created_at, updated_at` + max_inflight_tasks, generation, deleting, last_error, last_error_at, last_polled_at, created_at, updated_at` // CreateReviewWatch inserts a review watch and serializes Projects to JSON. func (s *Store) CreateReviewWatch(ctx context.Context, rw *ReviewWatch) error { @@ -167,17 +214,20 @@ func (s *Store) CreateReviewWatch(ctx context.Context, rw *ReviewWatch) error { if rw.ReviewScope == "" { rw.ReviewScope = ReviewScopeUserAndTeams } + if rw.Generation <= 0 { + rw.Generation = 1 + } rw.CleanupPolicy = NormalizeCleanupPolicy(rw.CleanupPolicy) _, err = s.db.NamedExecContext(ctx, ` INSERT INTO gitlab_review_watches ( id, workspace_id, workflow_id, workflow_step_id, projects, - agent_profile_id, executor_profile_id, prompt, review_scope, custom_query, - enabled, poll_interval_seconds, cleanup_policy, last_polled_at, + agent_profile_id, executor_profile_id, prompt, repository_id, base_branch, review_scope, custom_query, + enabled, poll_interval_seconds, cleanup_policy, max_inflight_tasks, generation, deleting, last_error, last_error_at, last_polled_at, created_at, updated_at ) VALUES ( :id, :workspace_id, :workflow_id, :workflow_step_id, :projects, - :agent_profile_id, :executor_profile_id, :prompt, :review_scope, :custom_query, - :enabled, :poll_interval_seconds, :cleanup_policy, :last_polled_at, + :agent_profile_id, :executor_profile_id, :prompt, :repository_id, :base_branch, :review_scope, :custom_query, + :enabled, :poll_interval_seconds, :cleanup_policy, :max_inflight_tasks, :generation, :deleting, :last_error, :last_error_at, :last_polled_at, :created_at, :updated_at )`, rw) return err @@ -185,9 +235,21 @@ func (s *Store) CreateReviewWatch(ctx context.Context, rw *ReviewWatch) error { // GetReviewWatch returns the review watch row by id. func (s *Store) GetReviewWatch(ctx context.Context, id string) (*ReviewWatch, error) { + return s.getReviewWatch(ctx, id, false) +} + +func (s *Store) GetReviewWatchIncludingDeleting(ctx context.Context, id string) (*ReviewWatch, error) { + return s.getReviewWatch(ctx, id, true) +} + +func (s *Store) getReviewWatch(ctx context.Context, id string, includeDeleting bool) (*ReviewWatch, error) { var rw ReviewWatch + deletingClause := " AND deleting = 0" + if includeDeleting { + deletingClause = "" + } err := s.ro.GetContext(ctx, &rw, - `SELECT `+reviewWatchSelectCols+` FROM gitlab_review_watches WHERE id = ?`, id) + `SELECT `+reviewWatchSelectCols+` FROM gitlab_review_watches WHERE id = ?`+deletingClause, id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -204,20 +266,26 @@ func (s *Store) GetReviewWatch(ctx context.Context, id string) (*ReviewWatch, er func (s *Store) ListReviewWatches(ctx context.Context, workspaceID string) ([]*ReviewWatch, error) { return s.listReviewWatches(ctx, `SELECT `+reviewWatchSelectCols+` FROM gitlab_review_watches - WHERE workspace_id = ? ORDER BY created_at ASC`, workspaceID) + WHERE workspace_id = ? AND deleting = 0 ORDER BY created_at ASC`, workspaceID) } // ListAllReviewWatches lists every review watch (used by the poller). func (s *Store) ListAllReviewWatches(ctx context.Context) ([]*ReviewWatch, error) { return s.listReviewWatches(ctx, - `SELECT `+reviewWatchSelectCols+` FROM gitlab_review_watches ORDER BY created_at ASC`) + `SELECT `+reviewWatchSelectCols+` FROM gitlab_review_watches WHERE deleting = 0 ORDER BY created_at ASC`) } // ListEnabledReviewWatches lists every enabled review watch. func (s *Store) ListEnabledReviewWatches(ctx context.Context) ([]*ReviewWatch, error) { return s.listReviewWatches(ctx, `SELECT `+reviewWatchSelectCols+` FROM gitlab_review_watches - WHERE enabled = 1 ORDER BY created_at ASC`) + WHERE enabled = 1 AND deleting = 0 ORDER BY created_at ASC`) +} + +func (s *Store) ListEnabledReviewWatchesForWorkspace(ctx context.Context, workspaceID string) ([]*ReviewWatch, error) { + return s.listReviewWatches(ctx, + `SELECT `+reviewWatchSelectCols+` FROM gitlab_review_watches + WHERE enabled = 1 AND deleting = 0 AND workspace_id = ? ORDER BY created_at ASC`, workspaceID) } func (s *Store) listReviewWatches(ctx context.Context, query string, args ...interface{}) ([]*ReviewWatch, error) { @@ -249,9 +317,11 @@ func (s *Store) UpdateReviewWatch(ctx context.Context, rw *ReviewWatch) error { workflow_id = :workflow_id, workflow_step_id = :workflow_step_id, projects = :projects, agent_profile_id = :agent_profile_id, executor_profile_id = :executor_profile_id, prompt = :prompt, + repository_id = :repository_id, base_branch = :base_branch, review_scope = :review_scope, custom_query = :custom_query, enabled = :enabled, poll_interval_seconds = :poll_interval_seconds, - cleanup_policy = :cleanup_policy, last_polled_at = :last_polled_at, + cleanup_policy = :cleanup_policy, max_inflight_tasks = :max_inflight_tasks, + last_error = :last_error, last_error_at = :last_error_at, last_polled_at = :last_polled_at, updated_at = :updated_at WHERE id = :id`, rw) if err != nil { @@ -272,16 +342,34 @@ func (s *Store) RecordReviewWatchPoll(ctx context.Context, id string, polledAt t return err } +func (s *Store) DisableReviewWatchWithError(ctx context.Context, id, cause string) error { + now := time.Now().UTC() + _, err := s.db.ExecContext(ctx, `UPDATE gitlab_review_watches + SET enabled = 0, last_error = ?, last_error_at = ?, updated_at = ? WHERE id = ?`, + cause, now, now, id) + return err +} + // DeleteReviewWatch removes a review watch by id. func (s *Store) DeleteReviewWatch(ctx context.Context, id string) error { - _, err := s.db.ExecContext(ctx, `DELETE FROM gitlab_review_watches WHERE id = ?`, id) - return err + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, `DELETE FROM gitlab_review_mr_tasks WHERE review_watch_id = ?`, id); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM gitlab_review_watches WHERE id = ?`, id); err != nil { + return err + } + return tx.Commit() } // --- Review MR Task dedup --- const reviewMRTaskSelectCols = `id, review_watch_id, project_path, mr_iid, - mr_url, task_id, created_at` + mr_url, task_id, generation, created_at` // HasReviewMRTask reports whether the given (watch, project, iid) is already // dedup-claimed. @@ -301,39 +389,77 @@ func (s *Store) HasReviewMRTask(ctx context.Context, reviewWatchID, projectPath // true if reservation succeeded (this caller wins the race), false if another // caller already reserved. func (s *Store) ReserveReviewMRTask(ctx context.Context, reviewWatchID, projectPath string, iid int, mrURL string) (bool, error) { + generation, err := s.reviewWatchGeneration(ctx, reviewWatchID) + if err != nil { + return false, err + } + return s.reserveReviewMRTask(ctx, reviewWatchID, generation, projectPath, iid, mrURL, false) +} + +func (s *Store) ReserveReviewMRTaskForGeneration(ctx context.Context, reviewWatchID string, generation int64, projectPath string, iid int, mrURL string) (bool, error) { + return s.reserveReviewMRTask(ctx, reviewWatchID, generation, projectPath, iid, mrURL, true) +} + +func (s *Store) reserveReviewMRTask(ctx context.Context, reviewWatchID string, generation int64, projectPath string, iid int, mrURL string, requireActive bool) (bool, error) { id := uuid.New().String() now := time.Now().UTC() - _, err := s.db.ExecContext(ctx, ` - INSERT INTO gitlab_review_mr_tasks (id, review_watch_id, project_path, mr_iid, mr_url, task_id, created_at) - VALUES (?, ?, ?, ?, ?, '', ?) - ON CONFLICT(review_watch_id, project_path, mr_iid) DO NOTHING`, - id, reviewWatchID, projectPath, iid, mrURL, now) + query := `INSERT INTO gitlab_review_mr_tasks + (id, review_watch_id, project_path, mr_iid, mr_url, task_id, generation, created_at) + VALUES (?, ?, ?, ?, ?, '', ?, ?) + ON CONFLICT(review_watch_id, project_path, mr_iid) DO NOTHING` + args := []interface{}{id, reviewWatchID, projectPath, iid, mrURL, generation, now} + if requireActive { + query = ` + INSERT INTO gitlab_review_mr_tasks (id, review_watch_id, project_path, mr_iid, mr_url, task_id, generation, created_at) + SELECT ?, ?, ?, ?, ?, '', ?, ? + WHERE EXISTS (SELECT 1 FROM gitlab_review_watches + WHERE id = ? AND generation = ? AND enabled = 1 AND deleting = 0) + ON CONFLICT(review_watch_id, project_path, mr_iid) DO NOTHING` + args = append(args, reviewWatchID, generation) + } + result, err := s.db.ExecContext(ctx, query, args...) if err != nil { return false, err } - // Check if our row landed. - var foundID string - err = s.ro.GetContext(ctx, &foundID, - `SELECT id FROM gitlab_review_mr_tasks - WHERE review_watch_id = ? AND project_path = ? AND mr_iid = ?`, - reviewWatchID, projectPath, iid) - if err != nil { - return false, err - } - return foundID == id, nil + rows, err := result.RowsAffected() + return rows == 1, err } // AssignReviewMRTaskID swaps the reserved placeholder task_id for the real id. func (s *Store) AssignReviewMRTaskID(ctx context.Context, reviewWatchID, projectPath string, iid int, taskID string) error { + generation, err := s.reviewWatchGeneration(ctx, reviewWatchID) + if err != nil { + return err + } + return s.assignReviewMRTaskID(ctx, reviewWatchID, generation, projectPath, iid, taskID, false) +} + +func (s *Store) AssignReviewMRTaskIDForGeneration(ctx context.Context, reviewWatchID string, generation int64, projectPath string, iid int, taskID string) error { + return s.assignReviewMRTaskID(ctx, reviewWatchID, generation, projectPath, iid, taskID, true) +} + +func (s *Store) assignReviewMRTaskID(ctx context.Context, reviewWatchID string, generation int64, projectPath string, iid int, taskID string, requireActive bool) error { + activeClause := "" + if requireActive { + activeClause = ` AND EXISTS (SELECT 1 FROM gitlab_review_watches + WHERE id = ? AND generation = ? AND enabled = 1 AND deleting = 0)` + } + args := []interface{}{taskID, reviewWatchID, projectPath, iid, generation} + if requireActive { + args = append(args, reviewWatchID, generation) + } res, err := s.db.ExecContext(ctx, ` UPDATE gitlab_review_mr_tasks SET task_id = ? - WHERE review_watch_id = ? AND project_path = ? AND mr_iid = ?`, - taskID, reviewWatchID, projectPath, iid) + WHERE review_watch_id = ? AND project_path = ? AND mr_iid = ? AND generation = ?`+activeClause, + args...) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { + if requireActive { + return ErrWatchOwnershipLost + } return fmt.Errorf("review MR task not found: watch=%s project=%s iid=%d", reviewWatchID, projectPath, iid) } return nil @@ -349,6 +475,26 @@ func (s *Store) ReleaseReviewMRTask(ctx context.Context, reviewWatchID, projectP return err } +func (s *Store) ReleaseReviewMRTaskForGeneration(ctx context.Context, reviewWatchID string, generation int64, projectPath string, iid int) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM gitlab_review_mr_tasks + WHERE review_watch_id = ? AND project_path = ? AND mr_iid = ? AND generation = ?`, + reviewWatchID, projectPath, iid, generation) + return err +} + +func (s *Store) reviewWatchGeneration(ctx context.Context, watchID string) (int64, error) { + var generation int64 + if err := s.ro.GetContext(ctx, &generation, + `SELECT generation FROM gitlab_review_watches WHERE id = ?`, watchID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 1, nil + } + return 0, err + } + return generation, nil +} + // ListReviewMRTasksByWatch returns dedup rows for a given watch. func (s *Store) ListReviewMRTasksByWatch(ctx context.Context, watchID string) ([]*ReviewMRTask, error) { var rows []ReviewMRTask @@ -378,6 +524,23 @@ func (s *Store) ListAllReviewMRTasks(ctx context.Context) ([]*ReviewMRTask, erro return out, nil } +func (s *Store) ListReviewMRTasksForWorkspace(ctx context.Context, workspaceID string) ([]*ReviewMRTask, error) { + var rows []ReviewMRTask + if err := s.ro.SelectContext(ctx, &rows, + `SELECT t.id, t.review_watch_id, t.project_path, t.mr_iid, + t.mr_url, t.task_id, t.generation, t.created_at + FROM gitlab_review_mr_tasks t + JOIN gitlab_review_watches w ON w.id = t.review_watch_id + WHERE w.workspace_id = ? ORDER BY t.created_at ASC`, workspaceID); err != nil { + return nil, err + } + out := make([]*ReviewMRTask, 0, len(rows)) + for i := range rows { + out = append(out, &rows[i]) + } + return out, nil +} + // DeleteReviewMRTask removes a dedup row by id. func (s *Store) DeleteReviewMRTask(ctx context.Context, id string) error { _, err := s.db.ExecContext(ctx, `DELETE FROM gitlab_review_mr_tasks WHERE id = ?`, id) @@ -387,8 +550,9 @@ func (s *Store) DeleteReviewMRTask(ctx context.Context, id string) error { // --- Issue Watch CRUD --- const issueWatchSelectCols = `id, workspace_id, workflow_id, workflow_step_id, - projects, agent_profile_id, executor_profile_id, prompt, labels, custom_query, - enabled, poll_interval_seconds, cleanup_policy, last_polled_at, created_at, updated_at` + projects, agent_profile_id, executor_profile_id, prompt, repository_id, base_branch, labels, custom_query, + enabled, poll_interval_seconds, cleanup_policy, max_inflight_tasks, last_error, last_error_at, + generation, deleting, last_polled_at, created_at, updated_at` // CreateIssueWatch inserts an issue watch and serializes Projects + Labels. func (s *Store) CreateIssueWatch(ctx context.Context, iw *IssueWatch) error { @@ -413,17 +577,20 @@ func (s *Store) CreateIssueWatch(ctx context.Context, iw *IssueWatch) error { if iw.PollIntervalSeconds <= 0 { iw.PollIntervalSeconds = 300 } + if iw.Generation <= 0 { + iw.Generation = 1 + } iw.CleanupPolicy = NormalizeCleanupPolicy(iw.CleanupPolicy) _, err = s.db.NamedExecContext(ctx, ` INSERT INTO gitlab_issue_watches ( id, workspace_id, workflow_id, workflow_step_id, projects, - agent_profile_id, executor_profile_id, prompt, labels, custom_query, - enabled, poll_interval_seconds, cleanup_policy, last_polled_at, + agent_profile_id, executor_profile_id, prompt, repository_id, base_branch, labels, custom_query, + enabled, poll_interval_seconds, cleanup_policy, max_inflight_tasks, generation, deleting, last_error, last_error_at, last_polled_at, created_at, updated_at ) VALUES ( :id, :workspace_id, :workflow_id, :workflow_step_id, :projects, - :agent_profile_id, :executor_profile_id, :prompt, :labels, :custom_query, - :enabled, :poll_interval_seconds, :cleanup_policy, :last_polled_at, + :agent_profile_id, :executor_profile_id, :prompt, :repository_id, :base_branch, :labels, :custom_query, + :enabled, :poll_interval_seconds, :cleanup_policy, :max_inflight_tasks, :generation, :deleting, :last_error, :last_error_at, :last_polled_at, :created_at, :updated_at )`, iw) return err @@ -431,9 +598,21 @@ func (s *Store) CreateIssueWatch(ctx context.Context, iw *IssueWatch) error { // GetIssueWatch returns an issue watch by id. func (s *Store) GetIssueWatch(ctx context.Context, id string) (*IssueWatch, error) { + return s.getIssueWatch(ctx, id, false) +} + +func (s *Store) GetIssueWatchIncludingDeleting(ctx context.Context, id string) (*IssueWatch, error) { + return s.getIssueWatch(ctx, id, true) +} + +func (s *Store) getIssueWatch(ctx context.Context, id string, includeDeleting bool) (*IssueWatch, error) { var iw IssueWatch + deletingClause := " AND deleting = 0" + if includeDeleting { + deletingClause = "" + } err := s.ro.GetContext(ctx, &iw, - `SELECT `+issueWatchSelectCols+` FROM gitlab_issue_watches WHERE id = ?`, id) + `SELECT `+issueWatchSelectCols+` FROM gitlab_issue_watches WHERE id = ?`+deletingClause, id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -450,20 +629,26 @@ func (s *Store) GetIssueWatch(ctx context.Context, id string) (*IssueWatch, erro func (s *Store) ListIssueWatches(ctx context.Context, workspaceID string) ([]*IssueWatch, error) { return s.listIssueWatches(ctx, `SELECT `+issueWatchSelectCols+` FROM gitlab_issue_watches - WHERE workspace_id = ? ORDER BY created_at ASC`, workspaceID) + WHERE workspace_id = ? AND deleting = 0 ORDER BY created_at ASC`, workspaceID) } // ListAllIssueWatches lists every issue watch. func (s *Store) ListAllIssueWatches(ctx context.Context) ([]*IssueWatch, error) { return s.listIssueWatches(ctx, - `SELECT `+issueWatchSelectCols+` FROM gitlab_issue_watches ORDER BY created_at ASC`) + `SELECT `+issueWatchSelectCols+` FROM gitlab_issue_watches WHERE deleting = 0 ORDER BY created_at ASC`) } // ListEnabledIssueWatches lists every enabled issue watch. func (s *Store) ListEnabledIssueWatches(ctx context.Context) ([]*IssueWatch, error) { return s.listIssueWatches(ctx, `SELECT `+issueWatchSelectCols+` FROM gitlab_issue_watches - WHERE enabled = 1 ORDER BY created_at ASC`) + WHERE enabled = 1 AND deleting = 0 ORDER BY created_at ASC`) +} + +func (s *Store) ListEnabledIssueWatchesForWorkspace(ctx context.Context, workspaceID string) ([]*IssueWatch, error) { + return s.listIssueWatches(ctx, + `SELECT `+issueWatchSelectCols+` FROM gitlab_issue_watches + WHERE enabled = 1 AND deleting = 0 AND workspace_id = ? ORDER BY created_at ASC`, workspaceID) } func (s *Store) listIssueWatches(ctx context.Context, query string, args ...interface{}) ([]*IssueWatch, error) { @@ -500,9 +685,11 @@ func (s *Store) UpdateIssueWatch(ctx context.Context, iw *IssueWatch) error { workflow_id = :workflow_id, workflow_step_id = :workflow_step_id, projects = :projects, agent_profile_id = :agent_profile_id, executor_profile_id = :executor_profile_id, prompt = :prompt, + repository_id = :repository_id, base_branch = :base_branch, labels = :labels, custom_query = :custom_query, enabled = :enabled, poll_interval_seconds = :poll_interval_seconds, - cleanup_policy = :cleanup_policy, last_polled_at = :last_polled_at, + cleanup_policy = :cleanup_policy, max_inflight_tasks = :max_inflight_tasks, + last_error = :last_error, last_error_at = :last_error_at, last_polled_at = :last_polled_at, updated_at = :updated_at WHERE id = :id`, iw) if err != nil { @@ -523,16 +710,34 @@ func (s *Store) RecordIssueWatchPoll(ctx context.Context, id string, polledAt ti return err } +func (s *Store) DisableIssueWatchWithError(ctx context.Context, id, cause string) error { + now := time.Now().UTC() + _, err := s.db.ExecContext(ctx, `UPDATE gitlab_issue_watches + SET enabled = 0, last_error = ?, last_error_at = ?, updated_at = ? WHERE id = ?`, + cause, now, now, id) + return err +} + // DeleteIssueWatch removes an issue watch. func (s *Store) DeleteIssueWatch(ctx context.Context, id string) error { - _, err := s.db.ExecContext(ctx, `DELETE FROM gitlab_issue_watches WHERE id = ?`, id) - return err + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, `DELETE FROM gitlab_issue_watch_tasks WHERE issue_watch_id = ?`, id); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM gitlab_issue_watches WHERE id = ?`, id); err != nil { + return err + } + return tx.Commit() } // --- Issue Watch Task dedup --- const issueWatchTaskSelectCols = `id, issue_watch_id, project_path, issue_iid, - issue_url, task_id, created_at` + issue_url, task_id, generation, created_at` // HasIssueWatchTask reports whether the (watch, project, iid) is dedup-claimed. func (s *Store) HasIssueWatchTask(ctx context.Context, issueWatchID, projectPath string, iid int) (bool, error) { @@ -549,38 +754,77 @@ func (s *Store) HasIssueWatchTask(ctx context.Context, issueWatchID, projectPath // ReserveIssueWatchTask inserts a placeholder row claiming dedup. func (s *Store) ReserveIssueWatchTask(ctx context.Context, issueWatchID, projectPath string, iid int, issueURL string) (bool, error) { + generation, err := s.issueWatchGeneration(ctx, issueWatchID) + if err != nil { + return false, err + } + return s.reserveIssueWatchTask(ctx, issueWatchID, generation, projectPath, iid, issueURL, false) +} + +func (s *Store) ReserveIssueWatchTaskForGeneration(ctx context.Context, issueWatchID string, generation int64, projectPath string, iid int, issueURL string) (bool, error) { + return s.reserveIssueWatchTask(ctx, issueWatchID, generation, projectPath, iid, issueURL, true) +} + +func (s *Store) reserveIssueWatchTask(ctx context.Context, issueWatchID string, generation int64, projectPath string, iid int, issueURL string, requireActive bool) (bool, error) { id := uuid.New().String() now := time.Now().UTC() - _, err := s.db.ExecContext(ctx, ` - INSERT INTO gitlab_issue_watch_tasks (id, issue_watch_id, project_path, issue_iid, issue_url, task_id, created_at) - VALUES (?, ?, ?, ?, ?, '', ?) - ON CONFLICT(issue_watch_id, project_path, issue_iid) DO NOTHING`, - id, issueWatchID, projectPath, iid, issueURL, now) + query := `INSERT INTO gitlab_issue_watch_tasks + (id, issue_watch_id, project_path, issue_iid, issue_url, task_id, generation, created_at) + VALUES (?, ?, ?, ?, ?, '', ?, ?) + ON CONFLICT(issue_watch_id, project_path, issue_iid) DO NOTHING` + args := []interface{}{id, issueWatchID, projectPath, iid, issueURL, generation, now} + if requireActive { + query = ` + INSERT INTO gitlab_issue_watch_tasks (id, issue_watch_id, project_path, issue_iid, issue_url, task_id, generation, created_at) + SELECT ?, ?, ?, ?, ?, '', ?, ? + WHERE EXISTS (SELECT 1 FROM gitlab_issue_watches + WHERE id = ? AND generation = ? AND enabled = 1 AND deleting = 0) + ON CONFLICT(issue_watch_id, project_path, issue_iid) DO NOTHING` + args = append(args, issueWatchID, generation) + } + result, err := s.db.ExecContext(ctx, query, args...) if err != nil { return false, err } - var foundID string - err = s.ro.GetContext(ctx, &foundID, - `SELECT id FROM gitlab_issue_watch_tasks - WHERE issue_watch_id = ? AND project_path = ? AND issue_iid = ?`, - issueWatchID, projectPath, iid) - if err != nil { - return false, err - } - return foundID == id, nil + rows, err := result.RowsAffected() + return rows == 1, err } // AssignIssueWatchTaskID swaps the placeholder for the real task_id. func (s *Store) AssignIssueWatchTaskID(ctx context.Context, issueWatchID, projectPath string, iid int, taskID string) error { + generation, err := s.issueWatchGeneration(ctx, issueWatchID) + if err != nil { + return err + } + return s.assignIssueWatchTaskID(ctx, issueWatchID, generation, projectPath, iid, taskID, false) +} + +func (s *Store) AssignIssueWatchTaskIDForGeneration(ctx context.Context, issueWatchID string, generation int64, projectPath string, iid int, taskID string) error { + return s.assignIssueWatchTaskID(ctx, issueWatchID, generation, projectPath, iid, taskID, true) +} + +func (s *Store) assignIssueWatchTaskID(ctx context.Context, issueWatchID string, generation int64, projectPath string, iid int, taskID string, requireActive bool) error { + activeClause := "" + if requireActive { + activeClause = ` AND EXISTS (SELECT 1 FROM gitlab_issue_watches + WHERE id = ? AND generation = ? AND enabled = 1 AND deleting = 0)` + } + args := []interface{}{taskID, issueWatchID, projectPath, iid, generation} + if requireActive { + args = append(args, issueWatchID, generation) + } res, err := s.db.ExecContext(ctx, ` UPDATE gitlab_issue_watch_tasks SET task_id = ? - WHERE issue_watch_id = ? AND project_path = ? AND issue_iid = ?`, - taskID, issueWatchID, projectPath, iid) + WHERE issue_watch_id = ? AND project_path = ? AND issue_iid = ? AND generation = ?`+activeClause, + args...) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { + if requireActive { + return ErrWatchOwnershipLost + } return fmt.Errorf("issue watch task not found: watch=%s project=%s iid=%d", issueWatchID, projectPath, iid) } return nil @@ -595,6 +839,26 @@ func (s *Store) ReleaseIssueWatchTask(ctx context.Context, issueWatchID, project return err } +func (s *Store) ReleaseIssueWatchTaskForGeneration(ctx context.Context, issueWatchID string, generation int64, projectPath string, iid int) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM gitlab_issue_watch_tasks + WHERE issue_watch_id = ? AND project_path = ? AND issue_iid = ? AND generation = ?`, + issueWatchID, projectPath, iid, generation) + return err +} + +func (s *Store) issueWatchGeneration(ctx context.Context, watchID string) (int64, error) { + var generation int64 + if err := s.ro.GetContext(ctx, &generation, + `SELECT generation FROM gitlab_issue_watches WHERE id = ?`, watchID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 1, nil + } + return 0, err + } + return generation, nil +} + // ListIssueWatchTasksByWatch lists dedup rows for an issue watch. func (s *Store) ListIssueWatchTasksByWatch(ctx context.Context, watchID string) ([]*IssueWatchTask, error) { var rows []IssueWatchTask @@ -624,6 +888,23 @@ func (s *Store) ListAllIssueWatchTasks(ctx context.Context) ([]*IssueWatchTask, return out, nil } +func (s *Store) ListIssueWatchTasksForWorkspace(ctx context.Context, workspaceID string) ([]*IssueWatchTask, error) { + var rows []IssueWatchTask + if err := s.ro.SelectContext(ctx, &rows, + `SELECT t.id, t.issue_watch_id, t.project_path, t.issue_iid, + t.issue_url, t.task_id, t.generation, t.created_at + FROM gitlab_issue_watch_tasks t + JOIN gitlab_issue_watches w ON w.id = t.issue_watch_id + WHERE w.workspace_id = ? ORDER BY t.created_at ASC`, workspaceID); err != nil { + return nil, err + } + out := make([]*IssueWatchTask, 0, len(rows)) + for i := range rows { + out = append(out, &rows[i]) + } + return out, nil +} + // DeleteIssueWatchTask removes a dedup row by id. func (s *Store) DeleteIssueWatchTask(ctx context.Context, id string) error { _, err := s.db.ExecContext(ctx, `DELETE FROM gitlab_issue_watch_tasks WHERE id = ?`, id) diff --git a/apps/backend/internal/gitlab/watch_models.go b/apps/backend/internal/gitlab/watch_models.go index 00d7b35fd0..061c37932f 100644 --- a/apps/backend/internal/gitlab/watch_models.go +++ b/apps/backend/internal/gitlab/watch_models.go @@ -83,11 +83,18 @@ type ReviewWatch struct { AgentProfileID string `json:"agent_profile_id" db:"agent_profile_id"` ExecutorProfileID string `json:"executor_profile_id" db:"executor_profile_id"` Prompt string `json:"prompt" db:"prompt"` + RepositoryID string `json:"repository_id,omitempty" db:"repository_id"` + BaseBranch string `json:"base_branch,omitempty" db:"base_branch"` ReviewScope string `json:"review_scope" db:"review_scope"` CustomQuery string `json:"custom_query" db:"custom_query"` Enabled bool `json:"enabled" db:"enabled"` PollIntervalSeconds int `json:"poll_interval_seconds" db:"poll_interval_seconds"` CleanupPolicy string `json:"cleanup_policy" db:"cleanup_policy"` + MaxInflightTasks *int `json:"max_inflight_tasks,omitempty" db:"max_inflight_tasks"` + Generation int64 `json:"-" db:"generation"` + Deleting bool `json:"-" db:"deleting"` + LastError string `json:"last_error,omitempty" db:"last_error"` + LastErrorAt *time.Time `json:"last_error_at,omitempty" db:"last_error_at"` LastPolledAt *time.Time `json:"last_polled_at,omitempty" db:"last_polled_at"` CreatedAt time.Time `json:"created_at" db:"created_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` @@ -101,6 +108,7 @@ type ReviewMRTask struct { MRIID int `json:"mr_iid" db:"mr_iid"` MRURL string `json:"mr_url" db:"mr_url"` TaskID string `json:"task_id" db:"task_id"` + Generation int64 `json:"-" db:"generation"` CreatedAt time.Time `json:"created_at" db:"created_at"` } @@ -115,12 +123,19 @@ type IssueWatch struct { AgentProfileID string `json:"agent_profile_id" db:"agent_profile_id"` ExecutorProfileID string `json:"executor_profile_id" db:"executor_profile_id"` Prompt string `json:"prompt" db:"prompt"` + RepositoryID string `json:"repository_id,omitempty" db:"repository_id"` + BaseBranch string `json:"base_branch,omitempty" db:"base_branch"` Labels []string `json:"labels" db:"-"` LabelsJSON string `json:"-" db:"labels"` CustomQuery string `json:"custom_query" db:"custom_query"` Enabled bool `json:"enabled" db:"enabled"` PollIntervalSeconds int `json:"poll_interval_seconds" db:"poll_interval_seconds"` CleanupPolicy string `json:"cleanup_policy" db:"cleanup_policy"` + MaxInflightTasks *int `json:"max_inflight_tasks,omitempty" db:"max_inflight_tasks"` + Generation int64 `json:"-" db:"generation"` + Deleting bool `json:"-" db:"deleting"` + LastError string `json:"last_error,omitempty" db:"last_error"` + LastErrorAt *time.Time `json:"last_error_at,omitempty" db:"last_error_at"` LastPolledAt *time.Time `json:"last_polled_at,omitempty" db:"last_polled_at"` CreatedAt time.Time `json:"created_at" db:"created_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` @@ -134,6 +149,7 @@ type IssueWatchTask struct { IssueIID int `json:"issue_iid" db:"issue_iid"` IssueURL string `json:"issue_url" db:"issue_url"` TaskID string `json:"task_id" db:"task_id"` + Generation int64 `json:"-" db:"generation"` CreatedAt time.Time `json:"created_at" db:"created_at"` } @@ -146,10 +162,13 @@ type CreateReviewWatchRequest struct { AgentProfileID string `json:"agent_profile_id"` ExecutorProfileID string `json:"executor_profile_id"` Prompt string `json:"prompt"` + RepositoryID string `json:"repository_id"` + BaseBranch string `json:"base_branch"` ReviewScope string `json:"review_scope"` CustomQuery string `json:"custom_query"` PollIntervalSeconds int `json:"poll_interval_seconds"` CleanupPolicy string `json:"cleanup_policy"` + MaxInflightTasks *int `json:"max_inflight_tasks,omitempty"` } // UpdateReviewWatchRequest is the request body for updating a review watch. @@ -160,11 +179,16 @@ type UpdateReviewWatchRequest struct { AgentProfileID *string `json:"agent_profile_id,omitempty"` ExecutorProfileID *string `json:"executor_profile_id,omitempty"` Prompt *string `json:"prompt,omitempty"` + RepositoryID *string `json:"repository_id,omitempty"` + BaseBranch *string `json:"base_branch,omitempty"` ReviewScope *string `json:"review_scope,omitempty"` CustomQuery *string `json:"custom_query,omitempty"` Enabled *bool `json:"enabled,omitempty"` PollIntervalSeconds *int `json:"poll_interval_seconds,omitempty"` CleanupPolicy *string `json:"cleanup_policy,omitempty"` + // MaxInflightTasks updates the cap; zero explicitly clears it, while an + // omitted field leaves the existing cap unchanged. + MaxInflightTasks *int `json:"max_inflight_tasks,omitempty"` } // CreateIssueWatchRequest is the request body for creating an issue watch. @@ -176,10 +200,13 @@ type CreateIssueWatchRequest struct { AgentProfileID string `json:"agent_profile_id"` ExecutorProfileID string `json:"executor_profile_id"` Prompt string `json:"prompt"` + RepositoryID string `json:"repository_id"` + BaseBranch string `json:"base_branch"` Labels []string `json:"labels"` CustomQuery string `json:"custom_query"` PollIntervalSeconds int `json:"poll_interval_seconds"` CleanupPolicy string `json:"cleanup_policy"` + MaxInflightTasks *int `json:"max_inflight_tasks,omitempty"` } // UpdateIssueWatchRequest is the request body for updating an issue watch. @@ -190,11 +217,16 @@ type UpdateIssueWatchRequest struct { AgentProfileID *string `json:"agent_profile_id,omitempty"` ExecutorProfileID *string `json:"executor_profile_id,omitempty"` Prompt *string `json:"prompt,omitempty"` + RepositoryID *string `json:"repository_id,omitempty"` + BaseBranch *string `json:"base_branch,omitempty"` Labels *[]string `json:"labels,omitempty"` CustomQuery *string `json:"custom_query,omitempty"` Enabled *bool `json:"enabled,omitempty"` PollIntervalSeconds *int `json:"poll_interval_seconds,omitempty"` CleanupPolicy *string `json:"cleanup_policy,omitempty"` + // MaxInflightTasks updates the cap; zero explicitly clears it, while an + // omitted field leaves the existing cap unchanged. + MaxInflightTasks *int `json:"max_inflight_tasks,omitempty"` } // MRFeedbackEvent is published when an MR has new feedback (new discussion, @@ -213,24 +245,32 @@ type MRFeedbackEvent struct { // review watch and a task has been auto-created. type NewReviewMREvent struct { ReviewWatchID string `json:"review_watch_id"` + WatchGeneration int64 `json:"watch_generation"` WorkspaceID string `json:"workspace_id"` WorkflowID string `json:"workflow_id"` WorkflowStepID string `json:"workflow_step_id"` AgentProfileID string `json:"agent_profile_id"` ExecutorProfileID string `json:"executor_profile_id"` Prompt string `json:"prompt"` + RepositoryID string `json:"repository_id,omitempty"` + BaseBranch string `json:"base_branch,omitempty"` + MaxInflightTasks *int `json:"max_inflight_tasks,omitempty"` MR *MR `json:"mr"` } // NewIssueEvent is published when a new issue matching a watch is found. type NewIssueEvent struct { IssueWatchID string `json:"issue_watch_id"` + WatchGeneration int64 `json:"watch_generation"` WorkspaceID string `json:"workspace_id"` WorkflowID string `json:"workflow_id"` WorkflowStepID string `json:"workflow_step_id"` AgentProfileID string `json:"agent_profile_id"` ExecutorProfileID string `json:"executor_profile_id"` Prompt string `json:"prompt"` + RepositoryID string `json:"repository_id,omitempty"` + BaseBranch string `json:"base_branch,omitempty"` + MaxInflightTasks *int `json:"max_inflight_tasks,omitempty"` Issue *Issue `json:"issue"` } diff --git a/apps/backend/internal/improvekandev/handler.go b/apps/backend/internal/improvekandev/handler.go index f468a3aab2..40e16403ec 100644 --- a/apps/backend/internal/improvekandev/handler.go +++ b/apps/backend/internal/improvekandev/handler.go @@ -184,7 +184,7 @@ func (h *Handler) httpBootstrap(c *gin.Context) { // kdlbs/kandev; backfill provider info on the match. // 3. Fall back to cloning into the managed location and registering it. func (h *Handler) resolveOrCloneRepo(ctx context.Context, workspaceID string) (*taskmodels.Repository, error) { - if existing, err := h.taskSvc.GetRepositoryByProviderInfo(ctx, workspaceID, repoProvider, repoOwner, repoName); err != nil { + if existing, err := h.taskSvc.GetRepositoryByProviderInfo(ctx, workspaceID, repoProvider, "https://github.com", repoOwner, repoName); err != nil { return nil, err } else if existing != nil { return existing, nil @@ -207,6 +207,7 @@ func (h *Handler) resolveOrCloneRepo(ctx context.Context, workspaceID string) (* repo, _, err := h.taskSvc.FindOrCreateRepository(ctx, &taskservice.FindOrCreateRepositoryRequest{ WorkspaceID: workspaceID, Provider: repoProvider, + ProviderHost: "https://github.com", ProviderOwner: repoOwner, ProviderName: repoName, DefaultBranch: defaultBranch, @@ -241,16 +242,18 @@ func findKandevRepoByLocalRemote(repos []*taskmodels.Repository, resolve remoteR // repo so subsequent lookups by provider info hit the fast path. Failures are // logged but non-fatal — we still return the matched repo to the caller. func (h *Handler) backfillKandevProviderInfo(ctx context.Context, repo *taskmodels.Repository) { - if repo.Provider == repoProvider && repo.ProviderOwner == repoOwner && repo.ProviderName == repoName { + if repo.Provider == repoProvider && repo.ProviderHost == "https://github.com" && + repo.ProviderOwner == repoOwner && repo.ProviderName == repoName { return } - provider, owner, name := repoProvider, repoOwner, repoName + provider, providerHost, owner, name := repoProvider, "https://github.com", repoOwner, repoName branch := repo.DefaultBranch if branch == "" { branch = defaultBranch } if _, err := h.taskSvc.UpdateRepository(ctx, repo.ID, &taskservice.UpdateRepositoryRequest{ Provider: &provider, + ProviderHost: &providerHost, ProviderOwner: &owner, ProviderName: &name, DefaultBranch: &branch, @@ -260,6 +263,7 @@ func (h *Handler) backfillKandevProviderInfo(ctx context.Context, repo *taskmode return } repo.Provider = provider + repo.ProviderHost = providerHost repo.ProviderOwner = owner repo.ProviderName = name repo.DefaultBranch = branch diff --git a/apps/backend/internal/office/testharness/routes.go b/apps/backend/internal/office/testharness/routes.go index e394728703..e57296012a 100644 --- a/apps/backend/internal/office/testharness/routes.go +++ b/apps/backend/internal/office/testharness/routes.go @@ -13,7 +13,10 @@ import ( "encoding/json" "errors" "net/http" + "net/url" "os" + "os/exec" + "strings" "time" "github.com/gin-gonic/gin" @@ -35,6 +38,8 @@ import ( // "true" to enable — matches the convention used by KANDEV_MOCK_JIRA etc. const EnvVar = "KANDEV_E2E_MOCK" +const gitLabRemoteURLEnv = "KANDEV_E2E_GITLAB_REMOTE_URL" + // errInvalidJSONPrefix is the shared 400 message for malformed request bodies. const errInvalidJSONPrefix = "invalid JSON: " @@ -93,6 +98,9 @@ func RegisterRoutes( g.POST("/task-sessions", seedTaskSessionHandler(repo, eventBus, log)) g.POST("/messages", seedMessageHandler(repo, eventBus, log)) g.POST("/workflows", seedWorkflowHandler(repo, log)) + g.PUT("/repositories/:id/git-remote", configureGitRemoteHandler(repo, log)) + g.DELETE("/repositories/:id/git-remote", configureGitRemoteHandler(repo, log)) + g.GET("/repositories/:id/git-push-record", gitPushRecordHandler(repo)) if officeRepo != nil { g.POST("/comments", seedCommentHandler(officeRepo, eventBus, log)) g.POST("/agent-failures", seedAgentFailureHandler(officeRepo, eventBus, log)) @@ -111,6 +119,111 @@ func RegisterRoutes( } } +type configureGitRemoteRequest struct { + RemoteURL string `json:"remote_url"` +} + +func configureGitRemoteHandler(repo *sqliterepo.Repository, log *logger.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + repository, err := repo.GetRepository(c.Request.Context(), c.Param("id")) + if err != nil || strings.TrimSpace(repository.LocalPath) == "" { + errJSON(c, http.StatusNotFound, "repository with a local path not found") + return + } + if c.Request.Method == http.MethodDelete { + removeGitRemote(c.Request.Context(), repository.LocalPath) + c.JSON(http.StatusOK, gin.H{"ok": true}) + return + } + + trustedRemoteURL, status, err := trustedGitRemoteURL(c) + if err != nil { + errJSON(c, status, err.Error()) + return + } + args := []string{"-C", repository.LocalPath, "remote", "add", "origin", trustedRemoteURL} + if getErr := exec.CommandContext(c.Request.Context(), "git", "-C", repository.LocalPath, + "remote", "get-url", "origin").Run(); getErr == nil { + args = []string{"-C", repository.LocalPath, "remote", "set-url", "origin", trustedRemoteURL} + } + if output, runErr := exec.CommandContext(c.Request.Context(), "git", args...).CombinedOutput(); runErr != nil { + log.Error("test harness: configure git remote failed", zap.Error(runErr)) + errJSON(c, http.StatusInternalServerError, strings.TrimSpace(string(output))) + return + } + if err := writeGitLabPushMarker(trustedRemoteURL); err != nil { + log.Error("test harness: write GitLab push marker failed", zap.Error(err)) + errJSON(c, http.StatusInternalServerError, "write GitLab push marker") + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) + } +} + +func removeGitRemote(ctx context.Context, repositoryPath string) { + _ = exec.CommandContext(ctx, "git", "-C", repositoryPath, "remote", "remove", "origin").Run() + for _, envKey := range []string{"KANDEV_E2E_GITLAB_PUSH_FILE", "KANDEV_E2E_GITLAB_PUSH_RECORD_FILE"} { + if path := os.Getenv(envKey); path != "" { + _ = os.Remove(path) + } + } +} + +func trustedGitRemoteURL(c *gin.Context) (string, int, error) { + var req configureGitRemoteRequest + if err := c.ShouldBindJSON(&req); err != nil { + return "", http.StatusBadRequest, errors.New(errInvalidJSONPrefix + err.Error()) + } + requestedRemote, err := localE2ERemoteURL(req.RemoteURL) + if err != nil { + return "", http.StatusBadRequest, errors.New("remote_url must use the local E2E server") + } + configuredRemote, err := localE2ERemoteURL(os.Getenv(gitLabRemoteURLEnv)) + if err != nil { + return "", http.StatusInternalServerError, errors.New("GitLab E2E remote is not configured") + } + if requestedRemote.String() != configuredRemote.String() { + return "", http.StatusBadRequest, errors.New("remote_url does not match the configured E2E remote") + } + return configuredRemote.String(), http.StatusOK, nil +} + +func localE2ERemoteURL(raw string) (*url.URL, error) { + remote, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || (remote.Hostname() != "localhost" && remote.Hostname() != "127.0.0.1") { + return nil, errors.New("remote is not local") + } + return remote, nil +} + +func writeGitLabPushMarker(remoteURL string) error { + marker := os.Getenv("KANDEV_E2E_GITLAB_PUSH_FILE") + if marker == "" { + return nil + } + return os.WriteFile(marker, []byte(remoteURL), 0o600) +} + +func gitPushRecordHandler(repo *sqliterepo.Repository) gin.HandlerFunc { + return func(c *gin.Context) { + if _, err := repo.GetRepository(c.Request.Context(), c.Param("id")); err != nil { + errJSON(c, http.StatusNotFound, "repository not found") + return + } + recordPath := os.Getenv("KANDEV_E2E_GITLAB_PUSH_RECORD_FILE") + if recordPath == "" { + errJSON(c, http.StatusNotFound, "git push was not recorded") + return + } + contents, err := os.ReadFile(recordPath) + if err != nil { + errJSON(c, http.StatusNotFound, "git push was not recorded") + return + } + c.JSON(http.StatusOK, gin.H{"args": strings.TrimSpace(string(contents))}) + } +} + type seedTaskRequest struct { WorkspaceID string `json:"workspace_id"` WorkflowID string `json:"workflow_id"` diff --git a/apps/backend/internal/office/testharness/routes_test.go b/apps/backend/internal/office/testharness/routes_test.go index e9f02f7a1e..cfd9be57c8 100644 --- a/apps/backend/internal/office/testharness/routes_test.go +++ b/apps/backend/internal/office/testharness/routes_test.go @@ -6,7 +6,10 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" + "os/exec" "path/filepath" + "strings" "testing" "time" @@ -102,6 +105,88 @@ func TestHealthRouteReturnsOK(t *testing.T) { } } +func TestConfigureGitRemoteRoute(t *testing.T) { + repo, sqlxDB := newTestRepo(t) + now := time.Now().UTC() + if _, err := sqlxDB.Exec(`INSERT INTO workspaces (id, name, created_at, updated_at) + VALUES ('ws-1', 'Test workspace', ?, ?)`, now, now); err != nil { + t.Fatalf("seed workspace: %v", err) + } + repoDir := filepath.Join(t.TempDir(), "repo") + if err := os.MkdirAll(repoDir, 0o755); err != nil { + t.Fatalf("mkdir repo: %v", err) + } + if output, err := exec.Command("git", "init", "-b", "main", repoDir).CombinedOutput(); err != nil { + t.Fatalf("git init: %v: %s", err, output) + } + model := &models.Repository{WorkspaceID: "ws-1", Name: "repo", SourceType: "local", LocalPath: repoDir} + if err := repo.CreateRepository(t.Context(), model); err != nil { + t.Fatalf("create repository: %v", err) + } + marker := filepath.Join(t.TempDir(), "gitlab-push") + record := filepath.Join(t.TempDir(), "gitlab-push-record") + t.Setenv("KANDEV_E2E_GITLAB_PUSH_FILE", marker) + t.Setenv("KANDEV_E2E_GITLAB_PUSH_RECORD_FILE", record) + router := newRouter(t, repo, nil) + remoteURL := "http://localhost:18080/group/project.git" + t.Setenv("KANDEV_E2E_GITLAB_REMOTE_URL", remoteURL) + unexpectedBody := mustJSON(t, map[string]string{ + "remote_url": "http://localhost:18080/group/unexpected.git", + }) + unexpectedReq := httptest.NewRequest(http.MethodPut, + "/api/v1/_test/repositories/"+model.ID+"/git-remote", bytes.NewReader(unexpectedBody)) + unexpectedReq.Header.Set("Content-Type", "application/json") + unexpectedRes := httptest.NewRecorder() + router.ServeHTTP(unexpectedRes, unexpectedReq) + if unexpectedRes.Code != http.StatusBadRequest { + t.Fatalf("unexpected remote status=%d body=%s", unexpectedRes.Code, unexpectedRes.Body.String()) + } + + body := mustJSON(t, map[string]string{"remote_url": remoteURL}) + req := httptest.NewRequest(http.MethodPut, + "/api/v1/_test/repositories/"+model.ID+"/git-remote", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", res.Code, res.Body.String()) + } + output, err := exec.Command("git", "-C", repoDir, "remote", "get-url", "origin").Output() + if err != nil || strings.TrimSpace(string(output)) != remoteURL { + t.Fatalf("remote=%q err=%v", output, err) + } + markerValue, err := os.ReadFile(marker) + if err != nil || string(markerValue) != remoteURL { + t.Fatalf("marker=%q err=%v", markerValue, err) + } + if err := os.WriteFile(record, []byte("push --set-upstream origin HEAD\n"), 0o600); err != nil { + t.Fatalf("write push record: %v", err) + } + pushReq := httptest.NewRequest(http.MethodGet, + "/api/v1/_test/repositories/"+model.ID+"/git-push-record", nil) + pushRes := httptest.NewRecorder() + router.ServeHTTP(pushRes, pushReq) + if pushRes.Code != http.StatusOK || !strings.Contains(pushRes.Body.String(), "origin HEAD") { + t.Fatalf("push record status=%d body=%s", pushRes.Code, pushRes.Body.String()) + } + remove := httptest.NewRequest(http.MethodDelete, + "/api/v1/_test/repositories/"+model.ID+"/git-remote", nil) + removed := httptest.NewRecorder() + router.ServeHTTP(removed, remove) + if removed.Code != http.StatusOK { + t.Fatalf("delete status=%d body=%s", removed.Code, removed.Body.String()) + } + if err := exec.Command("git", "-C", repoDir, "remote", "get-url", "origin").Run(); err == nil { + t.Fatal("origin remote still exists after cleanup") + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("push marker still exists after cleanup: %v", err) + } + if _, err := os.Stat(record); !os.IsNotExist(err) { + t.Fatalf("push record still exists after cleanup: %v", err) + } +} + func TestSeedTaskSessionHappyPath(t *testing.T) { repo, sqlxDB := newTestRepo(t) taskID := uuid.New().String() diff --git a/apps/backend/internal/orchestrator/event_handlers_gitlab.go b/apps/backend/internal/orchestrator/event_handlers_gitlab.go index dc96bc7d21..e8caa19808 100644 --- a/apps/backend/internal/orchestrator/event_handlers_gitlab.go +++ b/apps/backend/internal/orchestrator/event_handlers_gitlab.go @@ -8,36 +8,21 @@ import ( "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/gitlab" + "github.com/kandev/kandev/internal/orchestrator/executor" ) -// GitLabIssueTaskCreator creates a task from a GitLab issue + watch context. -// Mirrors github.IssueTaskCreator. Returns the created task id or an error. -type GitLabIssueTaskCreator interface { - CreateGitLabIssueTask(ctx context.Context, evt *gitlab.NewIssueEvent) (string, error) -} - -// GitLabReviewTaskCreator creates a task from a GitLab MR + watch context. -type GitLabReviewTaskCreator interface { - CreateGitLabReviewTask(ctx context.Context, evt *gitlab.NewReviewMREvent) (string, error) -} - -// SetGitLabIssueTaskCreator wires the issue→task creator used by the -// `gitlab.new_issue` handler. When nil, watches still fire but no tasks -// are auto-created (events are logged for observability). -func (s *Service) SetGitLabIssueTaskCreator(c GitLabIssueTaskCreator) { - s.gitlabIssueTaskCreator = c -} - -// SetGitLabReviewTaskCreator wires the review→task creator. -func (s *Service) SetGitLabReviewTaskCreator(c GitLabReviewTaskCreator) { - s.gitlabReviewTaskCreator = c -} - // SetGitLabService is the entry point for wiring the GitLab service into the // orchestrator so the event handlers can reserve dedup rows. Mirrors // SetGitHubService / SetJiraService. -func (s *Service) SetGitLabService(svc *gitlab.Service) { +func (s *Service) SetGitLabService(svc GitLabWatchService) { s.gitlabService = svc + s.gitlabReviewSource = NewGitLabReviewWatcherSource(svc, s.logger) + s.gitlabIssueSource = NewGitLabIssueWatcherSource(svc, s.logger) +} + +// SetGitLabCredentialResolver binds execution auth to the task workspace. +func (s *Service) SetGitLabCredentialResolver(resolver executor.GitLabCredentialResolver) { + s.executor.SetGitLabCredentialResolver(resolver) } // subscribeGitLabEvents wires bus subscriptions for the GitLab integration @@ -66,52 +51,17 @@ func (s *Service) handleGitLabNewReviewMR(ctx context.Context, event *bus.Event) zap.String("review_watch_id", evt.ReviewWatchID), zap.String("project", evt.MR.ProjectPath), zap.Int("iid", evt.MR.IID)) - if s.gitlabReviewTaskCreator == nil { - s.logger.Debug("gitlab review task creator not configured; skipping task creation") - return nil + src := s.gitlabReviewSource + if src == nil { + src = NewGitLabReviewWatcherSource(nil, s.logger) } - go s.createGitLabReviewTask(context.Background(), evt) + s.dispatchWatcherEvent(ctx, src, evt, + zap.String("review_watch_id", evt.ReviewWatchID), + zap.String("project", evt.MR.ProjectPath), + zap.Int("iid", evt.MR.IID)) return nil } -// a different domain type (MR vs Issue); merging them would obscure the -// per-event publishing contract. -// -//nolint:dupl // structurally similar to createGitLabIssueTask but operates on -func (s *Service) createGitLabReviewTask(ctx context.Context, evt *gitlab.NewReviewMREvent) { - if s.gitlabService == nil { - s.logger.Warn("gitlab service not configured; cannot reserve dedup") - return - } - mr := evt.MR - claimed, err := s.gitlabService.ReserveReviewMRTask(ctx, evt.ReviewWatchID, mr.ProjectPath, mr.IID, mr.WebURL) - if err != nil || !claimed { - if err != nil { - s.logger.Warn("reserve gitlab review MR task", zap.Error(err)) - } - return - } - taskID, err := s.gitlabReviewTaskCreator.CreateGitLabReviewTask(ctx, evt) - if err != nil { - s.logger.Warn("create gitlab review task", zap.Error(err)) - if relErr := s.gitlabService.ReleaseReviewMRTask(ctx, evt.ReviewWatchID, mr.ProjectPath, mr.IID); relErr != nil { - s.logger.Warn("release gitlab review MR dedup row after task-create failure", - zap.String("review_watch_id", evt.ReviewWatchID), - zap.String("project", mr.ProjectPath), - zap.Int("iid", mr.IID), - zap.Error(relErr)) - } - return - } - if err := s.gitlabService.AssignReviewMRTaskID(ctx, evt.ReviewWatchID, mr.ProjectPath, mr.IID, taskID); err != nil { - s.logger.Warn("assign gitlab review MR task id", zap.Error(err)) - } - s.logger.Info("gitlab review MR task created", - zap.String("task_id", taskID), - zap.String("project", mr.ProjectPath), - zap.Int("iid", mr.IID)) -} - // handleGitLabNewIssue mirrors handleGitLabNewReviewMR for issue events. func (s *Service) handleGitLabNewIssue(ctx context.Context, event *bus.Event) error { evt, ok := event.Data.(*gitlab.NewIssueEvent) @@ -122,45 +72,13 @@ func (s *Service) handleGitLabNewIssue(ctx context.Context, event *bus.Event) er zap.String("issue_watch_id", evt.IssueWatchID), zap.String("project", evt.Issue.ProjectPath), zap.Int("iid", evt.Issue.IID)) - if s.gitlabIssueTaskCreator == nil { - s.logger.Debug("gitlab issue task creator not configured; skipping task creation") - return nil + src := s.gitlabIssueSource + if src == nil { + src = NewGitLabIssueWatcherSource(nil, s.logger) } - go s.createGitLabIssueTask(context.Background(), evt) + s.dispatchWatcherEvent(ctx, src, evt, + zap.String("issue_watch_id", evt.IssueWatchID), + zap.String("project", evt.Issue.ProjectPath), + zap.Int("iid", evt.Issue.IID)) return nil } - -//nolint:dupl // see createGitLabReviewTask — same shape, different domain types. -func (s *Service) createGitLabIssueTask(ctx context.Context, evt *gitlab.NewIssueEvent) { - if s.gitlabService == nil { - s.logger.Warn("gitlab service not configured; cannot reserve dedup") - return - } - issue := evt.Issue - claimed, err := s.gitlabService.ReserveIssueWatchTask(ctx, evt.IssueWatchID, issue.ProjectPath, issue.IID, issue.WebURL) - if err != nil || !claimed { - if err != nil { - s.logger.Warn("reserve gitlab issue task", zap.Error(err)) - } - return - } - taskID, err := s.gitlabIssueTaskCreator.CreateGitLabIssueTask(ctx, evt) - if err != nil { - s.logger.Warn("create gitlab issue task", zap.Error(err)) - if relErr := s.gitlabService.ReleaseIssueWatchTask(ctx, evt.IssueWatchID, issue.ProjectPath, issue.IID); relErr != nil { - s.logger.Warn("release gitlab issue dedup row after task-create failure", - zap.String("issue_watch_id", evt.IssueWatchID), - zap.String("project", issue.ProjectPath), - zap.Int("iid", issue.IID), - zap.Error(relErr)) - } - return - } - if err := s.gitlabService.AssignIssueWatchTaskID(ctx, evt.IssueWatchID, issue.ProjectPath, issue.IID, taskID); err != nil { - s.logger.Warn("assign gitlab issue task id", zap.Error(err)) - } - s.logger.Info("gitlab issue task created", - zap.String("task_id", taskID), - zap.String("project", issue.ProjectPath), - zap.Int("iid", issue.IID)) -} diff --git a/apps/backend/internal/orchestrator/event_handlers_gitlab_dispatch_test.go b/apps/backend/internal/orchestrator/event_handlers_gitlab_dispatch_test.go new file mode 100644 index 0000000000..451922515a --- /dev/null +++ b/apps/backend/internal/orchestrator/event_handlers_gitlab_dispatch_test.go @@ -0,0 +1,61 @@ +package orchestrator + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/gitlab" + "github.com/kandev/kandev/internal/task/models" +) + +type channelIssueTaskCreator struct { + requests chan *IssueTaskRequest +} + +func (c *channelIssueTaskCreator) CreateIssueTask(_ context.Context, req *IssueTaskRequest) (*models.Task, error) { + c.requests <- req + return &models.Task{ID: "task-gitlab-1", Description: req.Description}, nil +} + +func TestGitLabReviewEventDispatchesThroughSharedIssueTaskCreator(t *testing.T) { + creator := &channelIssueTaskCreator{requests: make(chan *IssueTaskRequest, 1)} + svc := &Service{logger: nopLogger(t)} + svc.SetIssueTaskCreator(creator) + svc.SetGitLabService(nil) + + evt := &gitlab.NewReviewMREvent{ + ReviewWatchID: "review-watch-1", + WorkspaceID: "ws-1", + WorkflowID: "workflow-1", + WorkflowStepID: "step-1", + AgentProfileID: "agent-1", + ExecutorProfileID: "executor-1", + Prompt: "Review {{mr.url}}", + MR: &gitlab.MR{ + IID: 17, + Title: "Improve GitLab", + WebURL: "https://gitlab.example.com/group/project/-/merge_requests/17", + ProjectPath: "group/project", + }, + } + if err := svc.handleGitLabNewReviewMR(context.Background(), &bus.Event{Data: evt}); err != nil { + t.Fatalf("handle event: %v", err) + } + + select { + case req := <-creator.requests: + if req.Title != "[group/project!17] Improve GitLab" { + t.Fatalf("title = %q", req.Title) + } + if req.Description != "Review https://gitlab.example.com/group/project/-/merge_requests/17" { + t.Fatalf("description = %q", req.Description) + } + if req.Metadata["gitlab_review_watch_id"] != "review-watch-1" { + t.Fatalf("watch metadata = %#v", req.Metadata) + } + case <-time.After(time.Second): + t.Fatal("GitLab review event was not dispatched through IssueTaskCreator") + } +} diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index fbd8742fd9..618d472d8e 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -602,14 +602,21 @@ type ExecutorTypeCapabilities interface { ShouldApplyPreferredShell(executorType string) bool } +// GitLabCredentialResolver returns the configured origin and credential for +// exactly one workspace. Implementations must not fall back across workspaces. +type GitLabCredentialResolver interface { + ResolveGitLabExecutionCredentials(ctx context.Context, workspaceID string) (host, token string, err error) +} + // Executor manages agent execution for tasks type Executor struct { - agentManager AgentManagerClient - repo executorStore - secretStore secrets.SecretStore - shellPrefs ShellPreferenceProvider - capabilities ExecutorTypeCapabilities - logger *logger.Logger + agentManager AgentManagerClient + repo executorStore + secretStore secrets.SecretStore + shellPrefs ShellPreferenceProvider + capabilities ExecutorTypeCapabilities + gitlabCredentials GitLabCredentialResolver + logger *logger.Logger // Configuration retryLimit int @@ -689,9 +696,12 @@ func (e *Executor) taskEnvLock(taskID string) *sync.Mutex { // RepoCloner clones remote repositories to local disk. type RepoCloner interface { - EnsureCloned(ctx context.Context, cloneURL, owner, name string) (string, error) + EnsureClonedForProvider( + ctx context.Context, + cloneURL, provider, providerHost, owner, name, credentialHost, token string, + ) (string, error) // BuildCloneURL constructs a protocol-aware clone URL for the given provider/owner/name. - BuildCloneURL(provider, owner, name string) (string, error) + BuildCloneURLWithHost(provider, host, owner, name string) (string, error) } type authenticatedRepoCloner interface { @@ -701,8 +711,21 @@ type authenticatedRepoCloner interface { func (e *Executor) ensureClonedWithWorkspaceAuth( ctx context.Context, repo *models.Repository, cloneURL string, ) (string, error) { + if strings.EqualFold(repo.Provider, "gitlab") { + credentialHost, token := "", "" + if e.gitlabCredentials != nil { + credentialHost, token, _ = e.gitlabCredentials.ResolveGitLabExecutionCredentials(ctx, repo.WorkspaceID) + } + return e.repoCloner.EnsureClonedForProvider( + ctx, cloneURL, repo.Provider, repo.ProviderHost, + repo.ProviderOwner, repo.ProviderName, credentialHost, token, + ) + } if repo.Provider != "azure_devops" || !strings.HasPrefix(cloneURL, "https://") { - return e.repoCloner.EnsureCloned(ctx, cloneURL, repo.ProviderOwner, repo.ProviderName) + return e.repoCloner.EnsureClonedForProvider( + ctx, cloneURL, repo.Provider, repo.ProviderHost, + repo.ProviderOwner, repo.ProviderName, "", "", + ) } authCloner, ok := e.repoCloner.(authenticatedRepoCloner) if !ok || e.secretStore == nil { @@ -826,3 +849,8 @@ func (e *Executor) SetOnLaunchFailed(fn LaunchFailedFunc) { func (e *Executor) SetCapabilities(c ExecutorTypeCapabilities) { e.capabilities = c } + +// SetGitLabCredentialResolver wires workspace-scoped GitLab execution auth. +func (e *Executor) SetGitLabCredentialResolver(resolver GitLabCredentialResolver) { + e.gitlabCredentials = resolver +} diff --git a/apps/backend/internal/orchestrator/executor/executor_credentials.go b/apps/backend/internal/orchestrator/executor/executor_credentials.go index e5872c4e30..5ae39d79bf 100644 --- a/apps/backend/internal/orchestrator/executor/executor_credentials.go +++ b/apps/backend/internal/orchestrator/executor/executor_credentials.go @@ -4,7 +4,9 @@ import ( "context" "encoding/json" "fmt" + "net/url" "os/exec" + "strconv" "strings" "time" @@ -17,9 +19,99 @@ const ( // envGitHubToken is the environment variable name for GitHub authentication tokens. envGitHubToken = "GITHUB_TOKEN" // envGHToken is the gh CLI compatible environment variable name. - envGHToken = "GH_TOKEN" + envGHToken = "GH_TOKEN" + envGitLabToken = "GITLAB_TOKEN" + envGitLabHost = "GITLAB_HOST" + envKandevGitLabHost = "KANDEV_GITLAB_HOST" + gitLabCredentialHelper = `!f() { echo "username=oauth2"; echo "password=$GITLAB_TOKEN"; }; f` ) +// injectGitLabWorkspaceCredentials overrides any inherited/profile GitLab +// credential with the current workspace's configured connection. Empty +// values are deliberate: standalone executions otherwise inherit the backend +// process environment and could receive another workspace's fallback token. +func (e *Executor) injectGitLabWorkspaceCredentials(ctx context.Context, req *LaunchAgentRequest) { + if req.Env == nil { + req.Env = make(map[string]string) + } + req.Env[envGitLabToken] = "" + req.Env[envGitLabHost] = "" + req.Env[envKandevGitLabHost] = "" + removeGitLabCredentialHelpers(req.Env) + if e.gitlabCredentials == nil || req.WorkspaceID == "" { + return + } + host, token, err := e.gitlabCredentials.ResolveGitLabExecutionCredentials(ctx, req.WorkspaceID) + if err != nil || strings.TrimSpace(host) == "" { + e.logger.Debug("GitLab execution credentials unavailable for workspace") + return + } + req.Env[envGitLabToken] = strings.TrimSpace(token) + req.Env[envGitLabHost] = strings.TrimSpace(host) + req.Env[envKandevGitLabHost] = strings.TrimSpace(host) + appendGitLabCredentialHelper(req.Env, host) +} + +func removeGitLabCredentialHelpers(env map[string]string) { + count, _ := strconv.Atoi(env["GIT_CONFIG_COUNT"]) + type entry struct{ key, value string } + entries := make([]entry, 0, count) + for i := 0; i < count; i++ { + keyName := fmt.Sprintf("GIT_CONFIG_KEY_%d", i) + valueName := fmt.Sprintf("GIT_CONFIG_VALUE_%d", i) + key, keyOK := env[keyName] + value, valueOK := env[valueName] + delete(env, keyName) + delete(env, valueName) + if keyOK && valueOK && (!strings.HasPrefix(strings.ToLower(key), "credential.http") || value != gitLabCredentialHelper) { + entries = append(entries, entry{key: key, value: value}) + } + } + for i, item := range entries { + env[fmt.Sprintf("GIT_CONFIG_KEY_%d", i)] = item.key + env[fmt.Sprintf("GIT_CONFIG_VALUE_%d", i)] = item.value + } + if len(entries) == 0 { + delete(env, "GIT_CONFIG_COUNT") + } else { + env["GIT_CONFIG_COUNT"] = strconv.Itoa(len(entries)) + } +} + +func appendGitLabCredentialHelper(env map[string]string, host string) { + origin, err := url.Parse(strings.TrimSpace(host)) + if err != nil || (origin.Scheme != "http" && origin.Scheme != "https") || origin.Host == "" || origin.User != nil || (origin.Path != "" && origin.Path != "/") { + return + } + origin.Path = "" + origin.RawPath = "" + origin.RawQuery = "" + origin.Fragment = "" + + count, _ := strconv.Atoi(env["GIT_CONFIG_COUNT"]) + for i := 0; i < count; i++ { + key := env[fmt.Sprintf("GIT_CONFIG_KEY_%d", i)] + if strings.EqualFold(key, "credential."+origin.String()+".helper") { + env[fmt.Sprintf("GIT_CONFIG_VALUE_%d", i)] = gitLabCredentialHelper + return + } + } + env[fmt.Sprintf("GIT_CONFIG_KEY_%d", count)] = "credential." + origin.String() + ".helper" + env[fmt.Sprintf("GIT_CONFIG_VALUE_%d", count)] = gitLabCredentialHelper + env["GIT_CONFIG_COUNT"] = strconv.Itoa(count + 1) +} + +func cloneStringMap(values map[string]string) map[string]string { + if values == nil { + return nil + } + cloned := make(map[string]string, len(values)) + for key, value := range values { + cloned[key] = value + } + return cloned +} + // injectGitHubToken injects GITHUB_TOKEN and GH_TOKEN into the request env for remote executors. // It looks for a GITHUB_TOKEN secret in the secrets store and injects it if found. // Both env vars are set for compatibility: GITHUB_TOKEN for git, GH_TOKEN for gh CLI. diff --git a/apps/backend/internal/orchestrator/executor/executor_credentials_test.go b/apps/backend/internal/orchestrator/executor/executor_credentials_test.go index 6ce7c21ad0..e0afa827f6 100644 --- a/apps/backend/internal/orchestrator/executor/executor_credentials_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_credentials_test.go @@ -3,11 +3,111 @@ package executor import ( "context" "fmt" + "strings" "testing" "github.com/kandev/kandev/internal/secrets" + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" ) +type fakeGitLabCredentialResolver struct { + byWorkspace map[string]struct{ host, token string } +} + +func (f *fakeGitLabCredentialResolver) ResolveGitLabExecutionCredentials(_ context.Context, workspaceID string) (string, string, error) { + entry, ok := f.byWorkspace[workspaceID] + if !ok { + return "", "", fmt.Errorf("not configured") + } + return entry.host, entry.token, nil +} + +func TestInjectGitLabWorkspaceCredentialsIsolatesWorkspaces(t *testing.T) { + exec := newTestExecutor(t, &mockAgentManager{}, newMockRepository()) + exec.SetGitLabCredentialResolver(&fakeGitLabCredentialResolver{byWorkspace: map[string]struct{ host, token string }{ + "workspace-a": {host: "https://gitlab.a.example", token: "token-a"}, + "workspace-b": {host: "http://gitlab.b.internal", token: "token-b"}, + }}) + + requestA := &LaunchAgentRequest{WorkspaceID: "workspace-a", Env: map[string]string{envGitLabToken: "stale-token"}} + exec.injectGitLabWorkspaceCredentials(context.Background(), requestA) + if requestA.Env[envGitLabToken] != "token-a" || requestA.Env[envKandevGitLabHost] != "https://gitlab.a.example" { + t.Fatalf("workspace A env = %#v", requestA.Env) + } + requestB := &LaunchAgentRequest{WorkspaceID: "workspace-b"} + exec.injectGitLabWorkspaceCredentials(context.Background(), requestB) + if requestB.Env[envGitLabToken] != "token-b" || requestB.Env[envKandevGitLabHost] != "http://gitlab.b.internal" { + t.Fatalf("workspace B env = %#v", requestB.Env) + } + if strings.Contains(fmt.Sprint(requestB.Env), "token-a") { + t.Fatalf("workspace B env leaked workspace A token: %#v", requestB.Env) + } +} + +func TestInjectGitLabWorkspaceCredentialsClearsUnscopedInheritedToken(t *testing.T) { + exec := newTestExecutor(t, &mockAgentManager{}, newMockRepository()) + exec.SetGitLabCredentialResolver(&fakeGitLabCredentialResolver{byWorkspace: map[string]struct{ host, token string }{}}) + req := &LaunchAgentRequest{WorkspaceID: "unconfigured", Env: map[string]string{ + envGitLabToken: "global-token", + envKandevGitLabHost: "https://wrong.example", + }} + exec.injectGitLabWorkspaceCredentials(context.Background(), req) + if req.Env[envGitLabToken] != "" || req.Env[envKandevGitLabHost] != "" { + t.Fatalf("unconfigured workspace retained GitLab credentials: %#v", req.Env) + } +} + +func TestInjectGitLabWorkspaceCredentialsAddsExactOriginHelperWithoutToken(t *testing.T) { + exec := newTestExecutor(t, &mockAgentManager{}, newMockRepository()) + exec.SetGitLabCredentialResolver(&fakeGitLabCredentialResolver{byWorkspace: map[string]struct{ host, token string }{ + "workspace-a": {host: "http://gitlab.internal:8080", token: "glpat-secret"}, + }}) + req := &LaunchAgentRequest{WorkspaceID: "workspace-a", Env: map[string]string{ + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "safe.directory", + "GIT_CONFIG_VALUE_0": "*", + }} + + exec.injectGitLabWorkspaceCredentials(context.Background(), req) + + if got := req.Env["GIT_CONFIG_KEY_1"]; got != "credential.http://gitlab.internal:8080.helper" { + t.Fatalf("credential helper key = %q", got) + } + if got := req.Env["GIT_CONFIG_VALUE_1"]; got != gitLabCredentialHelper { + t.Fatalf("credential helper = %q", got) + } + if strings.Contains(req.Env["GIT_CONFIG_VALUE_1"], "glpat-secret") { + t.Fatal("credential helper persisted the token") + } +} + +func TestStartAgentOnExistingWorkspaceRefreshesWorkspaceGitLabCredentials(t *testing.T) { + var captured map[string]string + manager := &mockAgentManager{ + getExecutionIDForSessionFunc: func(context.Context, string) (string, error) { return "execution-1", nil }, + setExecutionEnvFunc: func(_ context.Context, _ string, env map[string]string) error { + captured = cloneStringMap(env) + return nil + }, + } + repo := newMockRepository() + exec := newTestExecutor(t, manager, repo) + exec.SetGitLabCredentialResolver(&fakeGitLabCredentialResolver{byWorkspace: map[string]struct{ host, token string }{ + "workspace-a": {host: "https://gitlab.a.example", token: "prepared-token"}, + }}) + task := &v1.Task{ID: "task-1", WorkspaceID: "workspace-a"} + session := &models.TaskSession{ID: "session-1", TaskID: task.ID, AgentProfileID: "profile-1"} + repo.sessions[session.ID] = session + + _, _ = exec.startAgentOnExistingWorkspace(context.Background(), task, session, "prompt", true, "", map[string]string{ + envGitLabToken: "stale-token", + }) + if captured[envGitLabToken] != "prepared-token" || captured[envKandevGitLabHost] != "https://gitlab.a.example" { + t.Fatalf("prepared workspace env = %#v", captured) + } +} + func TestIsContainerizedExecutor(t *testing.T) { tests := []struct { executorType string diff --git a/apps/backend/internal/orchestrator/executor/executor_environment_test.go b/apps/backend/internal/orchestrator/executor/executor_environment_test.go index 214cf3d9b2..97411dde2f 100644 --- a/apps/backend/internal/orchestrator/executor/executor_environment_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_environment_test.go @@ -330,6 +330,9 @@ func TestBuildResumeRequest_ReusesTaskEnvironmentRuntimeMetadata(t *testing.T) { func TestBuildResumeRequest_UsesExecutionProfileAndKeepsOfficeIdentity(t *testing.T) { repo := newMockRepository() exec := newTestExecutor(t, &mockAgentManager{}, repo) + exec.SetGitLabCredentialResolver(&fakeGitLabCredentialResolver{byWorkspace: map[string]struct{ host, token string }{ + "workspace-1": {host: "https://gitlab.example", token: "resume-token"}, + }}) task := &v1.Task{ID: "task-1", WorkspaceID: "workspace-1", Title: "Task 1"} session := &models.TaskSession{ ID: "session-1", @@ -359,6 +362,9 @@ func TestBuildResumeRequest_UsesExecutionProfileAndKeepsOfficeIdentity(t *testin if req.ACPSessionID != "claude-session" { t.Fatalf("ACPSessionID = %q, want matching profile token", req.ACPSessionID) } + if req.WorkspaceID != "workspace-1" || req.Env[envGitLabToken] != "resume-token" { + t.Fatalf("resume credentials not workspace scoped: workspace=%q env=%#v", req.WorkspaceID, req.Env) + } } func TestReuseExistingEnvironment_SandboxReuse(t *testing.T) { diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index 274b501f59..af4773d4ad 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -15,6 +15,7 @@ import ( "github.com/google/uuid" "github.com/kandev/kandev/internal/agent/runtime/lifecycle" "github.com/kandev/kandev/internal/orchestrator/sessionstate" + "github.com/kandev/kandev/internal/repoclone" "github.com/kandev/kandev/internal/sysprompt" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/worktree" @@ -484,8 +485,6 @@ func shouldUseWorktree(executorType string) bool { return models.ExecutorType(executorType) == models.ExecutorTypeWorktree } -const providerGitHub = "github" - // repositoryCloneURL builds a clone URL for the repository. It prefers the // provider info when present (HTTPS GitHub/GitLab/Bitbucket URL); otherwise // it inspects the local checkout's `origin` remote. The latter lets local-only @@ -496,18 +495,16 @@ func repositoryCloneURL(repo *models.Repository) string { return strings.TrimSpace(repo.RemoteURL) } if repo.ProviderOwner != "" && repo.ProviderName != "" { - var host string - switch strings.ToLower(repo.Provider) { - case providerGitHub, "": - host = "github.com" - case "gitlab": - host = "gitlab.com" - case "bitbucket": - host = "bitbucket.org" - default: + if strings.EqualFold(repo.Provider, "gitlab") && strings.TrimSpace(repo.ProviderHost) == "" { return "" } - return fmt.Sprintf("https://%s/%s/%s.git", host, repo.ProviderOwner, repo.ProviderName) + cloneURL, err := repoclone.CloneURLWithHost( + repo.Provider, repo.ProviderHost, repo.ProviderOwner, repo.ProviderName, repoclone.ProtocolHTTPS, + ) + if err != nil { + return "" + } + return cloneURL } if repo.LocalPath == "" { return "" @@ -1066,6 +1063,7 @@ func (e *Executor) buildLaunchAgentRequest(ctx context.Context, task *v1.Task, s if executorNeedsResolvedCredentials(execConfig.ExecutorType) { e.applyContainerCredentials(ctx, req, metadata) } + e.injectGitLabWorkspaceCredentials(ctx, req) req.WorktreeBranchTicket = worktree.TicketForBranchName(task.Identifier, metadata) metadata, err := e.applyRepositoryConfig(req, task, repoInfo, execConfig, metadata) @@ -1371,8 +1369,10 @@ func (e *Executor) startAgentOnExistingWorkspace(ctx context.Context, task *v1.T // Non-fatal: agent may start without description } } - if len(env) > 0 { - if err := e.agentManager.SetExecutionEnv(ctx, executionID, env); err != nil { + credentialReq := &LaunchAgentRequest{WorkspaceID: task.WorkspaceID, Env: cloneStringMap(env)} + e.injectGitLabWorkspaceCredentials(ctx, credentialReq) + if len(credentialReq.Env) > 0 { + if err := e.agentManager.SetExecutionEnv(ctx, executionID, credentialReq.Env); err != nil { e.logger.Warn("failed to set execution env for existing workspace", zap.String("session_id", session.ID), zap.String("agent_execution_id", executionID), diff --git a/apps/backend/internal/orchestrator/executor/executor_interaction.go b/apps/backend/internal/orchestrator/executor/executor_interaction.go index 817e42d89c..180bb30c6c 100644 --- a/apps/backend/internal/orchestrator/executor/executor_interaction.go +++ b/apps/backend/internal/orchestrator/executor/executor_interaction.go @@ -550,6 +550,7 @@ func (e *Executor) launchModelSwitchAgent(ctx context.Context, taskID, sessionID func (e *Executor) buildSwitchModelRequest(ctx context.Context, task *models.Task, session *models.TaskSession, sessionID, newModel, prompt, acpSessionID string, execConfig executorConfig, running *models.ExecutorRunning) (*LaunchAgentRequest, error) { req := &LaunchAgentRequest{ TaskID: task.ID, + WorkspaceID: task.WorkspaceID, SessionID: sessionID, TaskTitle: task.Title, AgentProfileID: session.AgentProfileID, @@ -561,6 +562,7 @@ func (e *Executor) buildSwitchModelRequest(ctx context.Context, task *models.Tas IsEphemeral: task.IsEphemeral, IsPassthrough: session.IsPassthrough, TaskEnvironmentID: session.TaskEnvironmentID, + Env: cloneStringMap(execConfig.ProfileEnv), } mcpMode, err := e.resolveTaskSessionMCPMode(ctx, task.ID, session) @@ -579,6 +581,7 @@ func (e *Executor) buildSwitchModelRequest(ctx context.Context, task *models.Tas if running != nil && running.WorktreePath != "" { req.RepositoryURL = running.WorktreePath } + e.injectGitLabWorkspaceCredentials(ctx, req) return req, nil } diff --git a/apps/backend/internal/orchestrator/executor/executor_interaction_test.go b/apps/backend/internal/orchestrator/executor/executor_interaction_test.go index f6b7830f14..e1d7db2c4e 100644 --- a/apps/backend/internal/orchestrator/executor/executor_interaction_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_interaction_test.go @@ -523,6 +523,9 @@ func TestSwitchModelFallback_PreservesRestrictedMCPMode(t *testing.T) { }, } exec := newTestExecutor(t, manager, repo) + exec.SetGitLabCredentialResolver(&fakeGitLabCredentialResolver{byWorkspace: map[string]struct{ host, token string }{ + "workspace-1": {host: "https://gitlab.example", token: "switch-token"}, + }}) if _, err := exec.SwitchModel( context.Background(), "task-office", "session-office", "new-model", "continue", @@ -535,6 +538,9 @@ func TestSwitchModelFallback_PreservesRestrictedMCPMode(t *testing.T) { if capturedReq.McpMode != tt.wantMode { t.Fatalf("McpMode = %q, want %q", capturedReq.McpMode, tt.wantMode) } + if capturedReq.WorkspaceID != "workspace-1" || capturedReq.Env[envGitLabToken] != "switch-token" { + t.Fatalf("model-switch credentials not workspace scoped: workspace=%q env=%#v", capturedReq.WorkspaceID, capturedReq.Env) + } }) } } diff --git a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go index e1773b4209..696feee69f 100644 --- a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go @@ -24,6 +24,7 @@ type mockAgentManager struct { stopAgentWithReasonFunc func(ctx context.Context, agentExecutionID string, reason string, force bool) error resolveAgentProfileFunc func(ctx context.Context, profileID string) (*AgentProfileInfo, error) setExecutionDescriptionFunc func(ctx context.Context, agentExecutionID string, description string) error + setExecutionEnvFunc func(ctx context.Context, agentExecutionID string, env map[string]string) error getExecutionIDForSessionFunc func(ctx context.Context, sessionID string) (string, error) isAgentCommandConfiguredFunc func(agentExecutionID string) bool isAgentRunningForSessionFunc func(ctx context.Context, sessionID string) bool @@ -65,8 +66,10 @@ func (m *mockAgentManager) SetExecutionDescription(ctx context.Context, agentExe } return nil } - -func (m *mockAgentManager) SetExecutionEnv(_ context.Context, _ string, _ map[string]string) error { +func (m *mockAgentManager) SetExecutionEnv(ctx context.Context, executionID string, env map[string]string) error { + if m.setExecutionEnvFunc != nil { + return m.setExecutionEnvFunc(ctx, executionID, env) + } return nil } @@ -952,7 +955,7 @@ func (m *mockRepository) ListRepositoryScripts(ctx context.Context, repositoryID func (m *mockRepository) ListScriptsByRepositoryIDs(_ context.Context, _ []string) (map[string][]*models.RepositoryScript, error) { return make(map[string][]*models.RepositoryScript), nil } -func (m *mockRepository) GetRepositoryByProviderInfo(_ context.Context, _, _, _, _ string) (*models.Repository, error) { +func (m *mockRepository) GetRepositoryByProviderInfo(_ context.Context, _, _, _, _, _ string) (*models.Repository, error) { return nil, nil } diff --git a/apps/backend/internal/orchestrator/executor/executor_resume.go b/apps/backend/internal/orchestrator/executor/executor_resume.go index f180260599..746177f356 100644 --- a/apps/backend/internal/orchestrator/executor/executor_resume.go +++ b/apps/backend/internal/orchestrator/executor/executor_resume.go @@ -151,7 +151,9 @@ func (e *Executor) ensureRepoCloned(ctx context.Context, repo *models.Repository return "", nil } - cloneURL, urlErr := e.repoCloner.BuildCloneURL(repo.Provider, repo.ProviderOwner, repo.ProviderName) + cloneURL, urlErr := e.repoCloner.BuildCloneURLWithHost( + repo.Provider, repo.ProviderHost, repo.ProviderOwner, repo.ProviderName, + ) if urlErr != nil || cloneURL == "" { // Fall back to HTTPS URL if BuildCloneURL fails cloneURL = repositoryCloneURL(repo) @@ -528,6 +530,7 @@ func (e *Executor) buildResumeRequest(ctx context.Context, task *v1.Task, sessio } req := &LaunchAgentRequest{ TaskID: task.ID, + WorkspaceID: task.WorkspaceID, SessionID: session.ID, TaskTitle: task.Title, AgentProfileID: executionProfileID, @@ -576,6 +579,7 @@ func (e *Executor) buildResumeRequest(ctx context.Context, task *v1.Task, sessio } existingRunning := e.applyRunningRecordToResumeRequest(ctx, req, task, session, startAgent) + e.injectGitLabWorkspaceCredentials(ctx, req) return req, repositoryID, execConfig, existingEnv, existingRunning, nil } diff --git a/apps/backend/internal/orchestrator/executor/executor_resume_clone_default_branch_test.go b/apps/backend/internal/orchestrator/executor/executor_resume_clone_default_branch_test.go index 0e41e1a639..d790072908 100644 --- a/apps/backend/internal/orchestrator/executor/executor_resume_clone_default_branch_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_resume_clone_default_branch_test.go @@ -216,11 +216,13 @@ func TestResolveTaskRepoInfo_BackfillIsBestEffortWhenLocalPathBroken(t *testing. // fakeRepoCloner returns a fixed local path for any clone request. type fakeRepoCloner struct{ returnPath string } -func (f *fakeRepoCloner) EnsureCloned(_ context.Context, _, _, _ string) (string, error) { +func (f *fakeRepoCloner) EnsureClonedForProvider( + _ context.Context, _, _, _, _, _, _, _ string, +) (string, error) { return f.returnPath, nil } -func (f *fakeRepoCloner) BuildCloneURL(_, owner, name string) (string, error) { +func (f *fakeRepoCloner) BuildCloneURLWithHost(_, _, owner, name string) (string, error) { return "https://github.com/" + owner + "/" + name + ".git", nil } diff --git a/apps/backend/internal/orchestrator/executor/executor_test.go b/apps/backend/internal/orchestrator/executor/executor_test.go index 34bb29eebf..f3aa5a3b59 100644 --- a/apps/backend/internal/orchestrator/executor/executor_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_test.go @@ -2291,9 +2291,14 @@ func TestRepositoryCloneURL(t *testing.T) { want: "https://github.com/acme/app.git", }, { - name: "gitlab repo", + name: "gitlab self-managed repo", + repo: &models.Repository{Provider: "gitlab", ProviderHost: "http://gitlab.internal", ProviderOwner: "group/subgroup", ProviderName: "app"}, + want: "http://gitlab.internal/group/subgroup/app.git", + }, + { + name: "gitlab unknown host fails closed", repo: &models.Repository{Provider: "gitlab", ProviderOwner: "acme", ProviderName: "app"}, - want: "https://gitlab.com/acme/app.git", + want: "", }, { name: "bitbucket repo", @@ -2341,14 +2346,14 @@ type recordingAuthenticatedCloner struct { password string } -func (c *recordingAuthenticatedCloner) EnsureCloned( - _ context.Context, _, _, _ string, +func (c *recordingAuthenticatedCloner) EnsureClonedForProvider( + _ context.Context, _, _, _, _, _, _, _ string, ) (string, error) { c.normalCalls++ return "/repos/normal", nil } -func (c *recordingAuthenticatedCloner) BuildCloneURL(_, _, _ string) (string, error) { +func (c *recordingAuthenticatedCloner) BuildCloneURLWithHost(_, _, _, _ string) (string, error) { return "", nil } diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 634b3afd7b..d5a238dca7 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -24,7 +24,6 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" - "github.com/kandev/kandev/internal/gitlab" "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/orchestrator/messagequeue" "github.com/kandev/kandev/internal/orchestrator/queue" @@ -351,9 +350,9 @@ type Service struct { // issue watch events. When the task creators are nil the events are // logged but no tasks are created — matches the GitHub flow when a // workspace has no task creator wired. - gitlabService *gitlab.Service - gitlabReviewTaskCreator GitLabReviewTaskCreator - gitlabIssueTaskCreator GitLabIssueTaskCreator + gitlabService GitLabWatchService + gitlabReviewSource *GitLabReviewWatcherSource + gitlabIssueSource *GitLabIssueWatcherSource // Repository resolver for cloning + finding/creating repos for review tasks repositoryResolver RepositoryResolver diff --git a/apps/backend/internal/orchestrator/source_gitlab.go b/apps/backend/internal/orchestrator/source_gitlab.go new file mode 100644 index 0000000000..c8a325a07e --- /dev/null +++ b/apps/backend/internal/orchestrator/source_gitlab.go @@ -0,0 +1,290 @@ +package orchestrator + +import ( + "context" + "errors" + "fmt" + "strings" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/gitlab" + "github.com/kandev/kandev/internal/task/models" +) + +const ( + gitlabReviewWatchMetadataKey = "gitlab_review_watch_id" + gitlabIssueWatchMetadataKey = "gitlab_issue_watch_id" +) + +// GitLabWatchService is the dedup and self-heal surface used by both GitLab +// watcher sources. Keeping it narrow lets the orchestrator own dispatch +// without depending on GitLab's polling implementation. +type GitLabWatchService interface { + ReserveReviewMRTask(context.Context, string, int64, string, int, string) (bool, error) + AssignReviewMRTaskID(context.Context, string, int64, string, int, string) error + ReleaseReviewMRTask(context.Context, string, int64, string, int) error + DisableReviewWatchWithError(context.Context, string, string) error + ReserveIssueWatchTask(context.Context, string, int64, string, int, string) (bool, error) + AssignIssueWatchTaskID(context.Context, string, int64, string, int, string) error + ReleaseIssueWatchTask(context.Context, string, int64, string, int) error + DisableIssueWatchWithError(context.Context, string, string) error +} + +type GitLabReviewWatcherSource struct { + service GitLabWatchService + logger *logger.Logger +} + +func NewGitLabReviewWatcherSource(svc GitLabWatchService, log *logger.Logger) *GitLabReviewWatcherSource { + return &GitLabReviewWatcherSource{service: svc, logger: log} +} + +func (s *GitLabReviewWatcherSource) Name() string { return "gitlab_review" } + +func (s *GitLabReviewWatcherSource) Reserve(ctx context.Context, evt any) (bool, error) { + e, err := gitlabReviewEvent(evt) + if err != nil { + return false, err + } + if s.service == nil { + return true, nil + } + return s.service.ReserveReviewMRTask(ctx, e.ReviewWatchID, e.WatchGeneration, e.MR.ProjectPath, e.MR.IID, e.MR.WebURL) +} + +func (s *GitLabReviewWatcherSource) Release(ctx context.Context, evt any) { + e, err := gitlabReviewEvent(evt) + if err != nil || s.service == nil { + return + } + if err := s.service.ReleaseReviewMRTask(ctx, e.ReviewWatchID, e.WatchGeneration, e.MR.ProjectPath, e.MR.IID); err != nil && s.logger != nil { + s.logger.Warn("gitlab review source: release failed", zap.Error(err)) + } +} + +func (s *GitLabReviewWatcherSource) BuildTaskRequest(evt any) (*IssueTaskRequest, error) { + e, err := gitlabReviewEvent(evt) + if err != nil { + return nil, err + } + req := gitlabTaskRequest(e.WorkspaceID, e.WorkflowID, e.WorkflowStepID, + fmt.Sprintf("[%s!%d] %s", e.MR.ProjectPath, e.MR.IID, e.MR.Title), + interpolateGitLabMRPrompt(e.Prompt, e.MR), e.RepositoryID, e.BaseBranch, + map[string]interface{}{ + gitlabReviewWatchMetadataKey: e.ReviewWatchID, + "gitlab_project_path": e.MR.ProjectPath, + "gitlab_mr_iid": e.MR.IID, + "gitlab_mr_url": e.MR.WebURL, + models.MetaKeyAgentProfileID: e.AgentProfileID, + models.MetaKeyExecutorProfileID: e.ExecutorProfileID, + }) + return req, nil +} + +func (s *GitLabReviewWatcherSource) AttachTaskID(ctx context.Context, evt any, taskID string) error { + e, err := gitlabReviewEvent(evt) + if err != nil || s.service == nil { + return err + } + return s.service.AssignReviewMRTaskID(ctx, e.ReviewWatchID, e.WatchGeneration, e.MR.ProjectPath, e.MR.IID, taskID) +} + +func (s *GitLabReviewWatcherSource) IsTerminalAttachError(err error) bool { + return errors.Is(err, gitlab.ErrWatchOwnershipLost) +} + +func (s *GitLabReviewWatcherSource) AutoStartParams(evt any) AutoStartParams { + e, err := gitlabReviewEvent(evt) + if err != nil { + return AutoStartParams{} + } + return gitlabAutoStartParams(e.AgentProfileID, e.ExecutorProfileID, e.WorkflowStepID) +} + +func (s *GitLabReviewWatcherSource) AgentProfileID(evt any) string { + e, _ := gitlabReviewEvent(evt) + if e == nil { + return "" + } + return e.AgentProfileID +} + +func (s *GitLabReviewWatcherSource) WatchID(evt any) string { + e, _ := gitlabReviewEvent(evt) + if e == nil { + return "" + } + return e.ReviewWatchID +} + +func (s *GitLabReviewWatcherSource) MaxInflightTasks(evt any) *int { + e, _ := gitlabReviewEvent(evt) + if e == nil { + return nil + } + return e.MaxInflightTasks +} + +func (s *GitLabReviewWatcherSource) WatchMetadataKey() string { return gitlabReviewWatchMetadataKey } + +func (s *GitLabReviewWatcherSource) SelfHeal(ctx context.Context, evt any, cause string) error { + e, err := gitlabReviewEvent(evt) + if err != nil || s.service == nil { + return err + } + return s.service.DisableReviewWatchWithError(ctx, e.ReviewWatchID, cause) +} + +type GitLabIssueWatcherSource struct { + service GitLabWatchService + logger *logger.Logger +} + +func NewGitLabIssueWatcherSource(svc GitLabWatchService, log *logger.Logger) *GitLabIssueWatcherSource { + return &GitLabIssueWatcherSource{service: svc, logger: log} +} + +func (s *GitLabIssueWatcherSource) Name() string { return "gitlab_issue" } + +func (s *GitLabIssueWatcherSource) Reserve(ctx context.Context, evt any) (bool, error) { + e, err := gitlabIssueEvent(evt) + if err != nil { + return false, err + } + if s.service == nil { + return true, nil + } + return s.service.ReserveIssueWatchTask(ctx, e.IssueWatchID, e.WatchGeneration, e.Issue.ProjectPath, e.Issue.IID, e.Issue.WebURL) +} + +func (s *GitLabIssueWatcherSource) Release(ctx context.Context, evt any) { + e, err := gitlabIssueEvent(evt) + if err != nil || s.service == nil { + return + } + if err := s.service.ReleaseIssueWatchTask(ctx, e.IssueWatchID, e.WatchGeneration, e.Issue.ProjectPath, e.Issue.IID); err != nil && s.logger != nil { + s.logger.Warn("gitlab issue source: release failed", zap.Error(err)) + } +} + +func (s *GitLabIssueWatcherSource) BuildTaskRequest(evt any) (*IssueTaskRequest, error) { + e, err := gitlabIssueEvent(evt) + if err != nil { + return nil, err + } + return gitlabTaskRequest(e.WorkspaceID, e.WorkflowID, e.WorkflowStepID, + fmt.Sprintf("[%s#%d] %s", e.Issue.ProjectPath, e.Issue.IID, e.Issue.Title), + interpolateGitLabIssuePrompt(e.Prompt, e.Issue), e.RepositoryID, e.BaseBranch, + map[string]interface{}{ + gitlabIssueWatchMetadataKey: e.IssueWatchID, + "gitlab_project_path": e.Issue.ProjectPath, + "gitlab_issue_iid": e.Issue.IID, + "gitlab_issue_url": e.Issue.WebURL, + models.MetaKeyAgentProfileID: e.AgentProfileID, + models.MetaKeyExecutorProfileID: e.ExecutorProfileID, + }), nil +} + +func (s *GitLabIssueWatcherSource) AttachTaskID(ctx context.Context, evt any, taskID string) error { + e, err := gitlabIssueEvent(evt) + if err != nil || s.service == nil { + return err + } + return s.service.AssignIssueWatchTaskID(ctx, e.IssueWatchID, e.WatchGeneration, e.Issue.ProjectPath, e.Issue.IID, taskID) +} + +func (s *GitLabIssueWatcherSource) IsTerminalAttachError(err error) bool { + return errors.Is(err, gitlab.ErrWatchOwnershipLost) +} + +func (s *GitLabIssueWatcherSource) AutoStartParams(evt any) AutoStartParams { + e, err := gitlabIssueEvent(evt) + if err != nil { + return AutoStartParams{} + } + return gitlabAutoStartParams(e.AgentProfileID, e.ExecutorProfileID, e.WorkflowStepID) +} + +func (s *GitLabIssueWatcherSource) AgentProfileID(evt any) string { + e, _ := gitlabIssueEvent(evt) + if e == nil { + return "" + } + return e.AgentProfileID +} + +func (s *GitLabIssueWatcherSource) WatchID(evt any) string { + e, _ := gitlabIssueEvent(evt) + if e == nil { + return "" + } + return e.IssueWatchID +} + +func (s *GitLabIssueWatcherSource) MaxInflightTasks(evt any) *int { + e, _ := gitlabIssueEvent(evt) + if e == nil { + return nil + } + return e.MaxInflightTasks +} + +func (s *GitLabIssueWatcherSource) WatchMetadataKey() string { return gitlabIssueWatchMetadataKey } + +func (s *GitLabIssueWatcherSource) SelfHeal(ctx context.Context, evt any, cause string) error { + e, err := gitlabIssueEvent(evt) + if err != nil || s.service == nil { + return err + } + return s.service.DisableIssueWatchWithError(ctx, e.IssueWatchID, cause) +} + +func gitlabReviewEvent(evt any) (*gitlab.NewReviewMREvent, error) { + e, ok := evt.(*gitlab.NewReviewMREvent) + if !ok || e == nil || e.MR == nil { + return nil, errors.New("gitlab review source: event payload missing or wrong type") + } + return e, nil +} + +func gitlabIssueEvent(evt any) (*gitlab.NewIssueEvent, error) { + e, ok := evt.(*gitlab.NewIssueEvent) + if !ok || e == nil || e.Issue == nil { + return nil, errors.New("gitlab issue source: event payload missing or wrong type") + } + return e, nil +} + +func gitlabTaskRequest(workspaceID, workflowID, stepID, title, description, repositoryID, baseBranch string, metadata map[string]interface{}) *IssueTaskRequest { + req := &IssueTaskRequest{WorkspaceID: workspaceID, WorkflowID: workflowID, WorkflowStepID: stepID, Title: title, Description: description, Metadata: metadata} + if repositoryID != "" { + req.Repositories = []IssueTaskRepository{{RepositoryID: repositoryID, BaseBranch: baseBranch}} + } + return req +} + +func gitlabAutoStartParams(agentID, executorID, stepID string) AutoStartParams { + return AutoStartParams{AgentProfileID: agentID, ExecutorProfileID: executorID, WorkflowStepID: stepID} +} + +func interpolateGitLabMRPrompt(template string, mr *gitlab.MR) string { + return strings.NewReplacer( + "{{mr.url}}", mr.WebURL, + "{{mr.title}}", mr.Title, + "{{mr.project}}", mr.ProjectPath, + "{{mr.iid}}", fmt.Sprint(mr.IID), + "{{mr.description}}", mr.Body, + ).Replace(template) +} + +func interpolateGitLabIssuePrompt(template string, issue *gitlab.Issue) string { + return strings.NewReplacer( + "{{issue.url}}", issue.WebURL, + "{{issue.title}}", issue.Title, + "{{issue.project}}", issue.ProjectPath, + "{{issue.iid}}", fmt.Sprint(issue.IID), + "{{issue.description}}", issue.Body, + ).Replace(template) +} diff --git a/apps/backend/internal/orchestrator/source_gitlab_test.go b/apps/backend/internal/orchestrator/source_gitlab_test.go new file mode 100644 index 0000000000..112e045fa9 --- /dev/null +++ b/apps/backend/internal/orchestrator/source_gitlab_test.go @@ -0,0 +1,138 @@ +package orchestrator + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/kandev/kandev/internal/gitlab" +) + +type fakeGitLabWatchService struct { + reviewReserved bool + issueReserved bool + assigned []string + released []string + disabled []string +} + +func (f *fakeGitLabWatchService) ReserveReviewMRTask(context.Context, string, int64, string, int, string) (bool, error) { + return f.reviewReserved, nil +} +func (f *fakeGitLabWatchService) AssignReviewMRTaskID(_ context.Context, watchID string, _ int64, project string, iid int, taskID string) error { + f.assigned = append(f.assigned, watchID+":"+project+":"+taskID) + return nil +} +func (f *fakeGitLabWatchService) ReleaseReviewMRTask(_ context.Context, watchID string, _ int64, project string, iid int) error { + f.released = append(f.released, watchID+":"+project) + return nil +} +func (f *fakeGitLabWatchService) DisableReviewWatchWithError(_ context.Context, watchID, cause string) error { + f.disabled = append(f.disabled, watchID+":"+cause) + return nil +} +func (f *fakeGitLabWatchService) ReserveIssueWatchTask(context.Context, string, int64, string, int, string) (bool, error) { + return f.issueReserved, nil +} +func (f *fakeGitLabWatchService) AssignIssueWatchTaskID(_ context.Context, watchID string, _ int64, project string, iid int, taskID string) error { + f.assigned = append(f.assigned, watchID+":"+project+":"+taskID) + return nil +} +func (f *fakeGitLabWatchService) ReleaseIssueWatchTask(_ context.Context, watchID string, _ int64, project string, iid int) error { + f.released = append(f.released, watchID+":"+project) + return nil +} +func (f *fakeGitLabWatchService) DisableIssueWatchWithError(_ context.Context, watchID, cause string) error { + f.disabled = append(f.disabled, watchID+":"+cause) + return nil +} + +func sampleGitLabReviewEvent() *gitlab.NewReviewMREvent { + cap := 3 + return &gitlab.NewReviewMREvent{ + ReviewWatchID: "rw-1", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", + AgentProfileID: "agent-1", ExecutorProfileID: "exec-1", Prompt: "Review {{mr.url}}", + RepositoryID: "repo-1", BaseBranch: "main", MaxInflightTasks: &cap, + MR: &gitlab.MR{IID: 4, Title: "Fix", ProjectPath: "group/project", WebURL: "https://gitlab/group/project/-/merge_requests/4"}, + } +} + +func sampleGitLabIssueEvent() *gitlab.NewIssueEvent { + return &gitlab.NewIssueEvent{ + IssueWatchID: "iw-1", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", + AgentProfileID: "agent-1", ExecutorProfileID: "exec-1", Prompt: "Handle {{issue.url}}", + Issue: &gitlab.Issue{IID: 8, Title: "Broken", ProjectPath: "group/project", WebURL: "https://gitlab/group/project/-/issues/8"}, + } +} + +func TestGitLabReviewWatcherSourceMapsDispatchData(t *testing.T) { + fake := &fakeGitLabWatchService{reviewReserved: true} + source := NewGitLabReviewWatcherSource(fake, nopLogger(t)) + event := sampleGitLabReviewEvent() + reserved, err := source.Reserve(context.Background(), event) + if err != nil || !reserved { + t.Fatalf("reserve=%v err=%v", reserved, err) + } + req, err := source.BuildTaskRequest(event) + if err != nil { + t.Fatalf("build request: %v", err) + } + if req.WorkspaceID != "ws-1" || req.WorkflowStepID != "step-1" || len(req.Repositories) != 1 { + t.Fatalf("request = %#v", req) + } + if req.Repositories[0].RepositoryID != "repo-1" || req.Repositories[0].BaseBranch != "main" { + t.Fatalf("repository = %#v", req.Repositories[0]) + } + if !strings.Contains(req.Description, event.MR.WebURL) || strings.Contains(req.Description, "{{") { + t.Fatalf("description = %q, want interpolated MR URL", req.Description) + } + if source.MaxInflightTasks(event) == nil || *source.MaxInflightTasks(event) != 3 { + t.Fatalf("max inflight = %v", source.MaxInflightTasks(event)) + } + if err := source.AttachTaskID(context.Background(), event, "task-1"); err != nil { + t.Fatalf("attach: %v", err) + } + if err := source.SelfHeal(context.Background(), event, "profile removed"); err != nil { + t.Fatalf("self heal: %v", err) + } + if len(fake.assigned) != 1 || len(fake.disabled) != 1 { + t.Fatalf("assigned=%v disabled=%v", fake.assigned, fake.disabled) + } +} + +func TestGitLabIssueWatcherSourceReleaseAndMetadata(t *testing.T) { + fake := &fakeGitLabWatchService{issueReserved: true} + source := NewGitLabIssueWatcherSource(fake, nopLogger(t)) + event := sampleGitLabIssueEvent() + req, err := source.BuildTaskRequest(event) + if err != nil { + t.Fatalf("build request: %v", err) + } + if req.Title != "[group/project#8] Broken" || req.Metadata[gitlabIssueWatchMetadataKey] != "iw-1" { + t.Fatalf("request = %#v", req) + } + if !strings.Contains(req.Description, event.Issue.WebURL) || strings.Contains(req.Description, "{{") { + t.Fatalf("description = %q, want interpolated issue URL", req.Description) + } + source.Release(context.Background(), event) + if len(fake.released) != 1 { + t.Fatalf("released = %v", fake.released) + } +} + +func TestGitLabWatcherSourcesClassifyOwnershipLossAsTerminal(t *testing.T) { + review := NewGitLabReviewWatcherSource(nil, nopLogger(t)) + issue := NewGitLabIssueWatcherSource(nil, nopLogger(t)) + for name, source := range map[string]interface{ IsTerminalAttachError(error) bool }{ + "review": review, + "issue": issue, + } { + if !source.IsTerminalAttachError(gitlab.ErrWatchOwnershipLost) { + t.Fatalf("%s source did not classify ownership loss as terminal", name) + } + if source.IsTerminalAttachError(errors.New("temporary attach failure")) { + t.Fatalf("%s source classified temporary failure as terminal", name) + } + } +} diff --git a/apps/backend/internal/orchestrator/watcher_dispatch.go b/apps/backend/internal/orchestrator/watcher_dispatch.go index 7d51a5ba4f..a4e818ba34 100644 --- a/apps/backend/internal/orchestrator/watcher_dispatch.go +++ b/apps/backend/internal/orchestrator/watcher_dispatch.go @@ -194,6 +194,13 @@ type WatcherSource interface { SelfHeal(ctx context.Context, evt any, cause string) error } +// terminalAttachErrorClassifier marks attachment failures where the task no +// longer belongs to the event's watch generation. The source has already +// performed any required cleanup, so dispatch must stop before auto-start. +type terminalAttachErrorClassifier interface { + IsTerminalAttachError(error) bool +} + // preflightDeletedProfile returns true when the watcher's bound profile has // been soft-deleted (the production bug that orphans watchers via the // reconciler's cleanup of disabled agent types). On a true return the @@ -363,6 +370,9 @@ func (c *WatcherDispatchCoordinator) Dispatch(ctx context.Context, src WatcherSo zap.Error(err)) // Do NOT release here — matches existing Linear/Jira behaviour: // attach is a best-effort step, the task is already created. + if classifier, ok := src.(terminalAttachErrorClassifier); ok && classifier.IsTerminalAttachError(err) { + return + } } c.logger.Info("watcher dispatch: created issue task", diff --git a/apps/backend/internal/orchestrator/watcher_dispatch_test.go b/apps/backend/internal/orchestrator/watcher_dispatch_test.go index 932af05cd3..57e1f46b08 100644 --- a/apps/backend/internal/orchestrator/watcher_dispatch_test.go +++ b/apps/backend/internal/orchestrator/watcher_dispatch_test.go @@ -32,6 +32,7 @@ type fakeWatcherSource struct { buildReq *IssueTaskRequest buildErr error attachErr error + terminalAttach bool autoStart AutoStartParams watchID string metadataKey string @@ -64,6 +65,8 @@ func (f *fakeWatcherSource) AttachTaskID(_ context.Context, _ any, _ string) err return f.attachErr } +func (f *fakeWatcherSource) IsTerminalAttachError(error) bool { return f.terminalAttach } + func (f *fakeWatcherSource) AutoStartParams(_ any) AutoStartParams { p := f.autoStart f.recordedAutoArgs = &p @@ -289,6 +292,28 @@ func TestCoordinator_Dispatch_AttachError_LogsButContinues(t *testing.T) { } } +func TestCoordinator_Dispatch_TerminalAttachErrorSkipsAutoStart(t *testing.T) { + src := &fakeWatcherSource{ + name: "gitlab_review", + reserveOK: true, + buildReq: &IssueTaskRequest{WorkspaceID: "ws-1", WorkflowStepID: "step-1"}, + attachErr: errors.New("watch ownership lost"), + terminalAttach: true, + } + taskCreator := &fakeTaskCreator{} + starter := &fakeTaskStarter{} + coordinator := newTestCoordinator(t, taskCreator, true, starter) + + coordinator.Dispatch(context.Background(), src, "evt") + + if starter.called != 0 { + t.Fatalf("auto-start called %d times after terminal attach error", starter.called) + } + if src.recordedRelease != 0 { + t.Fatalf("terminal ownership loss released replacement reservation %d times", src.recordedRelease) + } +} + func TestCoordinator_Dispatch_AutoStartError_LogsAndReturns(t *testing.T) { src := &fakeWatcherSource{ name: "linear", diff --git a/apps/backend/internal/profiles/profiles.yaml b/apps/backend/internal/profiles/profiles.yaml index d65f86ea0d..ae35f4c606 100644 --- a/apps/backend/internal/profiles/profiles.yaml +++ b/apps/backend/internal/profiles/profiles.yaml @@ -77,6 +77,10 @@ mocks: prod: "" dev: "" e2e: "true" + KANDEV_MOCK_GITLAB: + prod: "" + dev: "" + e2e: "true" KANDEV_MOCK_JIRA: prod: "" dev: "" diff --git a/apps/backend/internal/repoclone/clone.go b/apps/backend/internal/repoclone/clone.go index 112f1317e0..1fa839ac5d 100644 --- a/apps/backend/internal/repoclone/clone.go +++ b/apps/backend/internal/repoclone/clone.go @@ -4,7 +4,9 @@ package repoclone import ( "context" "encoding/base64" + "errors" "fmt" + "net/url" "os" "os/exec" "path/filepath" @@ -21,6 +23,8 @@ import ( const ( ghCredentialHelper = "!gh auth git-credential" gitNoTags = "--no-tags" + protocolHTTP = "http" + providerCloneDir = "_providers" ) // Config holds configuration for the repository cloner. @@ -75,6 +79,12 @@ func (c *Cloner) BuildCloneURL(provider, owner, name string) (string, error) { return CloneURL(provider, owner, name, c.protocol) } +// BuildCloneURLWithHost constructs a clone URL using a persisted provider +// origin. This is required for self-managed GitLab repositories. +func (c *Cloner) BuildCloneURLWithHost(provider, host, owner, name string) (string, error) { + return CloneURLWithHost(provider, host, owner, name, c.protocol) +} + // RepoPath returns the full local path for a repository. func (c *Cloner) RepoPath(owner, name string) (string, error) { basePath, err := c.ExpandedBasePath() @@ -92,15 +102,93 @@ func (c *Cloner) RepoPath(owner, name string) (string, error) { return targetPath, nil } +// ProviderRepoPath returns a clone path isolated by provider and provider host. +// Repositories can share owner/name across GitHub, GitLab.com, and self-managed +// GitLab instances, so owner/name alone is not a stable clone identity. +func (c *Cloner) ProviderRepoPath(provider, providerHost, owner, name string) (string, error) { + basePath, err := c.ExpandedBasePath() + if err != nil { + return "", err + } + return filepath.Join( + basePath, + providerCloneDir, + clonePathSegment(provider), + providerHostPathSegment(providerHost), + owner, + name, + ), nil +} + +func providerHostPathSegment(providerHost string) string { + host := strings.TrimSpace(providerHost) + if parsed, err := url.Parse(host); err == nil && parsed.Host != "" { + host = parsed.Host + } + return clonePathSegment(host) +} + +func clonePathSegment(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + var result strings.Builder + result.Grow(len(value)) + lastWasSeparator := false + for _, char := range value { + allowed := char >= 'a' && char <= 'z' || char >= '0' && char <= '9' || char == '.' || char == '_' + if allowed { + result.WriteRune(char) + lastWasSeparator = false + } else if !lastWasSeparator { + result.WriteByte('-') + lastWasSeparator = true + } + } + segment := strings.Trim(result.String(), "-.") + if segment == "" { + return "unknown" + } + return segment +} + // EnsureCloned clones the repository if it doesn't exist locally, or fetches if it does. // The cloneURL is the full git URL (HTTPS or SSH) to clone from. // Returns the local filesystem path to the repository. // Concurrent calls for the same repository are serialised to prevent double-clone races. func (c *Cloner) EnsureCloned(ctx context.Context, cloneURL, owner, name string) (string, error) { + return c.EnsureClonedWithAuth(ctx, cloneURL, owner, name, "", "") +} + +// EnsureClonedWithAuth scopes an HTTPS credential to one exact provider +// origin. The token is passed only through the child process environment and +// is never embedded in the clone URL or persisted in .git/config. +func (c *Cloner) EnsureClonedWithAuth( + ctx context.Context, + cloneURL, owner, name, credentialOrigin, token string, +) (string, error) { targetPath, err := c.RepoPath(owner, name) if err != nil { return "", err } + return c.ensureClonedAtPath(ctx, cloneURL, targetPath, owner, name, credentialOrigin, token) +} + +// EnsureClonedForProvider clones or fetches a provider-backed repository using +// a path keyed by both provider and host. +func (c *Cloner) EnsureClonedForProvider( + ctx context.Context, + cloneURL, provider, providerHost, owner, name, credentialOrigin, token string, +) (string, error) { + targetPath, err := c.ProviderRepoPath(provider, providerHost, owner, name) + if err != nil { + return "", err + } + return c.ensureClonedAtPath(ctx, cloneURL, targetPath, owner, name, credentialOrigin, token) +} + +func (c *Cloner) ensureClonedAtPath( + ctx context.Context, + cloneURL, targetPath, owner, name, credentialOrigin, token string, +) (string, error) { mu := c.repoMu(targetPath) mu.Lock() @@ -108,11 +196,11 @@ func (c *Cloner) EnsureCloned(ctx context.Context, cloneURL, owner, name string) gitDir := filepath.Join(targetPath, ".git") if info, statErr := os.Stat(gitDir); statErr == nil && info.IsDir() { - c.fetch(ctx, targetPath) + c.fetch(ctx, targetPath, cloneURL, credentialOrigin, token) return targetPath, nil } - return targetPath, c.clone(ctx, cloneURL, targetPath, owner, name) + return targetPath, c.clone(ctx, cloneURL, targetPath, owner, name, credentialOrigin, token) } // EnsureClonedWithBasicAuth clones or fetches using an ephemeral HTTP @@ -162,11 +250,20 @@ func (c *Cloner) gitCmdWithHTTPHeader(ctx context.Context, header string, args . // gitCmd creates a git command with non-interactive environment settings. // When the configured protocol is HTTPS, it adds gh CLI as the credential // helper so that git can authenticate using the user's gh auth token. -func (c *Cloner) gitCmd(ctx context.Context, args ...string) *exec.Cmd { +func (c *Cloner) gitCmd( + ctx context.Context, + cloneURL, credentialOrigin, token string, + args ...string, +) (*exec.Cmd, error) { cmd := exec.CommandContext(ctx, "git", args...) env := os.Environ() env = append(env, "GIT_TERMINAL_PROMPT=0") - if c.protocol == ProtocolHTTPS { + credentialEnv, err := gitLabCredentialEnv(cloneURL, credentialOrigin, token) + if err != nil { + return nil, err + } + env = append(env, credentialEnv...) + if token == "" && c.protocol == ProtocolHTTPS { env = append(env, "GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=credential.helper", @@ -174,12 +271,85 @@ func (c *Cloner) gitCmd(ctx context.Context, args ...string) *exec.Cmd { ) } cmd.Env = env - return cmd + return cmd, nil } -func (c *Cloner) fetch(ctx context.Context, repoPath string) { +func gitLabCredentialEnv(cloneURL, credentialOrigin, token string) ([]string, error) { + if token == "" { + return nil, nil + } + if !isHTTPCloneURL(cloneURL) { + return nil, validateSSHCredentialHost(cloneURL, credentialOrigin) + } + origin, err := matchingCredentialOrigin(cloneURL, credentialOrigin) + if err != nil { + return nil, err + } + return []string{ + "KANDEV_GIT_CREDENTIAL_TOKEN=" + token, + "GIT_CONFIG_COUNT=1", + "GIT_CONFIG_KEY_0=credential." + origin + ".helper", + `GIT_CONFIG_VALUE_0=!f() { echo "username=oauth2"; echo "password=$KANDEV_GIT_CREDENTIAL_TOKEN"; }; f`, + }, nil +} + +func isHTTPCloneURL(cloneURL string) bool { + parsed, err := url.Parse(strings.TrimSpace(cloneURL)) + return err == nil && (parsed.Scheme == protocolHTTP || parsed.Scheme == ProtocolHTTPS) +} + +func validateSSHCredentialHost(cloneURL, credentialOrigin string) error { + credential, err := url.Parse(strings.TrimRight(strings.TrimSpace(credentialOrigin), "/")) + if err != nil || (credential.Scheme != protocolHTTP && credential.Scheme != ProtocolHTTPS) || + credential.Hostname() == "" || credential.User != nil || + (credential.Path != "" && credential.Path != "/") || credential.RawQuery != "" || credential.Fragment != "" { + return errors.New("configured credential host is invalid") + } + remoteHost := sshCloneHostname(cloneURL) + if remoteHost == "" || !strings.EqualFold(remoteHost, credential.Hostname()) { + return errors.New("clone URL does not match the configured credential host") + } + return nil +} + +func sshCloneHostname(cloneURL string) string { + trimmed := strings.TrimSpace(cloneURL) + if strings.HasPrefix(trimmed, "ssh://") { + parsed, err := url.Parse(trimmed) + if err == nil && parsed.Hostname() != "" && parsed.Path != "" { + return parsed.Hostname() + } + return "" + } + if _, after, ok := strings.Cut(trimmed, "@"); ok { + trimmed = after + } + host, path, ok := strings.Cut(trimmed, ":") + if !ok || host == "" || path == "" { + return "" + } + return host +} + +func matchingCredentialOrigin(cloneURL, credentialOrigin string) (string, error) { + clone, cloneErr := url.Parse(strings.TrimSpace(cloneURL)) + credential, credentialErr := url.Parse(strings.TrimRight(strings.TrimSpace(credentialOrigin), "/")) + if cloneErr != nil || credentialErr != nil || clone.User != nil || credential.User != nil || + (clone.Scheme != protocolHTTP && clone.Scheme != ProtocolHTTPS) || + clone.Scheme != credential.Scheme || !strings.EqualFold(clone.Host, credential.Host) || + (credential.Path != "" && credential.Path != "/") { + return "", errors.New("clone URL does not match the configured credential host") + } + return credential.Scheme + "://" + credential.Host, nil +} + +func (c *Cloner) fetch(ctx context.Context, repoPath, cloneURL, credentialOrigin, token string) { c.logger.Debug("repository already cloned, fetching", zap.String("path", repoPath)) - cmd := c.gitCmd(ctx, "-C", repoPath, "fetch", "--all", "--prune", "--force", gitNoTags) + cmd, err := c.gitCmd(ctx, cloneURL, credentialOrigin, token, "-C", repoPath, "fetch", "--all", "--prune", "--force", gitNoTags) + if err != nil { + c.logger.Warn("git fetch credential host mismatch", zap.String("path", repoPath)) + return + } if out, err := subproc.RunGitCombinedOutput(ctx, cmd); err != nil { c.logger.Warn("git fetch failed (non-fatal)", zap.String("path", repoPath), @@ -188,18 +358,18 @@ func (c *Cloner) fetch(ctx context.Context, repoPath string) { } } -func (c *Cloner) clone(ctx context.Context, cloneURL, targetPath, owner, name string) error { +func (c *Cloner) clone(ctx context.Context, cloneURL, targetPath, owner, name, credentialOrigin, token string) error { parentDir := filepath.Dir(targetPath) if err := os.MkdirAll(parentDir, 0o755); err != nil { return fmt.Errorf("create parent directory: %w", err) } c.logger.Info("cloning repository", - zap.String("url", cloneURL), + zap.String("url", redactCloneURL(cloneURL)), zap.String("target", targetPath)) // Try gh repo clone first — handles auth for both SSH and HTTPS. - if owner != "" && name != "" { + if owner != "" && name != "" && strings.Contains(cloneURL, "github.com") { if err := c.ghClone(ctx, owner, name, targetPath); err == nil { return nil } @@ -210,13 +380,32 @@ func (c *Cloner) clone(ctx context.Context, cloneURL, targetPath, owner, name st } // Fallback: git clone with credential helper. - cmd := c.gitCmd(ctx, "clone", "--filter=blob:none", gitNoTags, cloneURL, targetPath) + cmd, err := c.gitCmd(ctx, cloneURL, credentialOrigin, token, "clone", "--filter=blob:none", gitNoTags, cloneURL, targetPath) + if err != nil { + return err + } if out, err := subproc.RunGitCombinedOutput(ctx, cmd); err != nil { - return fmt.Errorf("git clone failed: %s: %w", string(out), err) + return fmt.Errorf("git clone failed: %s: %w", redactCloneOutput(string(out), token), err) } return nil } +func redactCloneURL(raw string) string { + parsed, err := url.Parse(raw) + if err != nil { + return raw + } + parsed.User = nil + return parsed.String() +} + +func redactCloneOutput(output, token string) string { + if token == "" { + return output + } + return strings.ReplaceAll(output, token, "[REDACTED]") +} + // ghClone attempts to clone using gh repo clone, which handles authentication // automatically via the user's gh CLI session. func (c *Cloner) ghClone(ctx context.Context, owner, name, targetPath string) error { diff --git a/apps/backend/internal/repoclone/clone_test.go b/apps/backend/internal/repoclone/clone_test.go index 441670c11b..1f5ce7b0f4 100644 --- a/apps/backend/internal/repoclone/clone_test.go +++ b/apps/backend/internal/repoclone/clone_test.go @@ -5,11 +5,37 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "github.com/kandev/kandev/internal/common/logger" ) +func TestProviderRepoPathSeparatesProviderHosts(t *testing.T) { + t.Parallel() + + cloner := NewCloner(Config{}, ProtocolHTTPS, t.TempDir(), logger.Default()) + gitHubPath, err := cloner.ProviderRepoPath("github", "https://github.com", "group", "project") + if err != nil { + t.Fatalf("GitHub ProviderRepoPath: %v", err) + } + gitLabPath, err := cloner.ProviderRepoPath("gitlab", "https://gitlab.com", "group", "project") + if err != nil { + t.Fatalf("GitLab ProviderRepoPath: %v", err) + } + selfManagedPath, err := cloner.ProviderRepoPath("gitlab", "https://gitlab.internal:8443", "group", "project") + if err != nil { + t.Fatalf("self-managed GitLab ProviderRepoPath: %v", err) + } + + if gitHubPath == gitLabPath || gitLabPath == selfManagedPath || gitHubPath == selfManagedPath { + t.Fatalf("provider paths collided: github=%q gitlab=%q self-managed=%q", gitHubPath, gitLabPath, selfManagedPath) + } + if !strings.Contains(selfManagedPath, filepath.Join("gitlab", "gitlab.internal-8443")) { + t.Fatalf("self-managed path %q does not contain normalized provider and host", selfManagedPath) + } +} + func TestClone_PreservesNonDefaultRemoteBranches(t *testing.T) { t.Parallel() @@ -17,7 +43,7 @@ func TestClone_PreservesNonDefaultRemoteBranches(t *testing.T) { targetPath := filepath.Join(t.TempDir(), "clone") cloner := NewCloner(Config{}, ProtocolSSH, t.TempDir(), logger.Default()) - if err := cloner.clone(context.Background(), originPath, targetPath, "", ""); err != nil { + if err := cloner.clone(context.Background(), originPath, targetPath, "", "", "", ""); err != nil { t.Fatalf("clone() unexpected error: %v", err) } @@ -57,6 +83,67 @@ func TestRepoPathConfinesRepositoryToCloneBase(t *testing.T) { } } +func TestGitCmdBindsGitLabCredentialToExactOrigin(t *testing.T) { + cloner := NewCloner(Config{}, ProtocolHTTPS, t.TempDir(), logger.Default()) + cmd, err := cloner.gitCmd( + context.Background(), + "https://gitlab.internal/group/repo.git", + "https://gitlab.internal", + "workspace-token", + "clone", + ) + if err != nil { + t.Fatalf("gitCmd: %v", err) + } + joined := strings.Join(cmd.Env, "\n") + if !strings.Contains(joined, "KANDEV_GIT_CREDENTIAL_TOKEN=workspace-token") || + !strings.Contains(joined, "GIT_CONFIG_KEY_0=credential.https://gitlab.internal.helper") { + t.Fatalf("credential env = %s", joined) + } + if _, err := cloner.gitCmd( + context.Background(), + "https://gitlab.com/group/repo.git", + "https://gitlab.internal", + "must-not-leak", + "clone", + ); err == nil { + t.Fatal("expected cross-host credential binding to fail") + } +} + +func TestGitCmdUsesSSHAuthForMatchingWorkspaceGitLabHost(t *testing.T) { + cloner := NewCloner(Config{}, ProtocolSSH, t.TempDir(), logger.Default()) + for _, cloneURL := range []string{ + "git@gitlab.internal:group/repo.git", + "ssh://git@gitlab.internal:2222/group/repo.git", + } { + cmd, err := cloner.gitCmd( + context.Background(), cloneURL, "https://gitlab.internal:8443", "workspace-token", "clone", + ) + if err != nil { + t.Fatalf("gitCmd(%q): %v", cloneURL, err) + } + joined := strings.Join(cmd.Env, "\n") + if strings.Contains(joined, "workspace-token") || strings.Contains(joined, "credential.gitlab") { + t.Fatalf("SSH clone received HTTP credentials: %s", joined) + } + } +} + +func TestGitCmdRejectsSSHCloneForDifferentWorkspaceGitLabHost(t *testing.T) { + cloner := NewCloner(Config{}, ProtocolSSH, t.TempDir(), logger.Default()) + for _, cloneURL := range []string{ + "git@gitlab.other:group/repo.git", + "ssh://git@gitlab.other:2222/group/repo.git", + } { + if _, err := cloner.gitCmd( + context.Background(), cloneURL, "https://gitlab.internal", "must-not-leak", "clone", + ); err == nil { + t.Fatalf("gitCmd accepted mismatched SSH clone %q", cloneURL) + } + } +} + func initBareRepoWithReleaseBranch(t *testing.T) string { t.Helper() diff --git a/apps/backend/internal/repoclone/protocol.go b/apps/backend/internal/repoclone/protocol.go index 59e645014f..5e6d0e5b0b 100644 --- a/apps/backend/internal/repoclone/protocol.go +++ b/apps/backend/internal/repoclone/protocol.go @@ -3,6 +3,7 @@ package repoclone import ( "context" "fmt" + "net/url" "os/exec" "strings" "time" @@ -44,8 +45,16 @@ func CloneURL(provider, owner, name, protocol string) (string, error) { // provider's default — used for self-managed GitLab. When host is empty, // behaves exactly like CloneURL. func CloneURLWithHost(provider, host, owner, name, protocol string) (string, error) { - resolvedHost := strings.TrimRight(host, "/") - resolvedHost = strings.TrimPrefix(strings.TrimPrefix(resolvedHost, "https://"), "http://") + resolvedHost := strings.TrimSpace(strings.TrimRight(host, "/")) + httpsScheme := "https" + if strings.Contains(resolvedHost, "://") { + parsed, err := url.Parse(resolvedHost) + if err != nil || parsed.Host == "" || parsed.User != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || (parsed.Path != "" && parsed.Path != "/") { + return "", fmt.Errorf("invalid git provider host: %q", host) + } + resolvedHost = parsed.Host + httpsScheme = parsed.Scheme + } if resolvedHost == "" { var err error resolvedHost, err = providerHost(provider) @@ -62,7 +71,7 @@ func CloneURLWithHost(provider, host, owner, name, protocol string) (string, err } return fmt.Sprintf("git@%s:%s/%s.git", resolvedHost, owner, name), nil } - return fmt.Sprintf("https://%s/%s/%s.git", resolvedHost, owner, name), nil + return fmt.Sprintf("%s://%s/%s/%s.git", httpsScheme, resolvedHost, owner, name), nil } // providerHost maps a provider name to its git host. diff --git a/apps/backend/internal/repoclone/protocol_test.go b/apps/backend/internal/repoclone/protocol_test.go index 30d0286737..2b7ac763a8 100644 --- a/apps/backend/internal/repoclone/protocol_test.go +++ b/apps/backend/internal/repoclone/protocol_test.go @@ -91,7 +91,7 @@ func TestCloneURLWithHost(t *testing.T) { { "http scheme honored", "gitlab", "http://gitlab.local", "team", "service", ProtocolHTTPS, - "https://gitlab.local/team/service.git", + "http://gitlab.local/team/service.git", }, { // scp-style "git@host:path" can't carry a port; ssh:// URL diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 805394ebe0..ac433dedac 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -51,6 +51,7 @@ type RepositoryDTO struct { LocalPath string `json:"local_path"` Provider string `json:"provider"` ProviderRepoID string `json:"provider_repo_id"` + ProviderHost string `json:"provider_host"` ProviderOwner string `json:"provider_owner"` ProviderName string `json:"provider_name"` DefaultBranch string `json:"default_branch"` @@ -472,6 +473,7 @@ func FromRepository(repository *models.Repository) RepositoryDTO { LocalPath: repository.LocalPath, Provider: repository.Provider, ProviderRepoID: repository.ProviderRepoID, + ProviderHost: repository.ProviderHost, ProviderOwner: repository.ProviderOwner, ProviderName: repository.ProviderName, DefaultBranch: repository.DefaultBranch, diff --git a/apps/backend/internal/task/dto/requests.go b/apps/backend/internal/task/dto/requests.go index ffc5857cc5..676cdcf685 100644 --- a/apps/backend/internal/task/dto/requests.go +++ b/apps/backend/internal/task/dto/requests.go @@ -74,6 +74,7 @@ type CreateRepositoryRequest struct { LocalPath string Provider string ProviderRepoID string + ProviderHost string ProviderOwner string ProviderName string DefaultBranch string @@ -93,6 +94,7 @@ type UpdateRepositoryRequest struct { LocalPath *string Provider *string ProviderRepoID *string + ProviderHost *string ProviderOwner *string ProviderName *string DefaultBranch *string diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index e40aee9384..d1b99ee064 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -372,7 +372,7 @@ func (m *mockRepository) ListRepositoryScripts(ctx context.Context, repositoryID func (m *mockRepository) ListScriptsByRepositoryIDs(_ context.Context, _ []string) (map[string][]*models.RepositoryScript, error) { return make(map[string][]*models.RepositoryScript), nil } -func (m *mockRepository) GetRepositoryByProviderInfo(_ context.Context, _, _, _, _ string) (*models.Repository, error) { +func (m *mockRepository) GetRepositoryByProviderInfo(_ context.Context, _, _, _, _, _ string) (*models.Repository, error) { return nil, nil } func (m *mockRepository) CreateExecutor(ctx context.Context, executor *models.Executor) error { diff --git a/apps/backend/internal/task/handlers/repository_handlers.go b/apps/backend/internal/task/handlers/repository_handlers.go index 4608d01262..03bbbe41ac 100644 --- a/apps/backend/internal/task/handlers/repository_handlers.go +++ b/apps/backend/internal/task/handlers/repository_handlers.go @@ -266,6 +266,7 @@ type httpCreateRepositoryRequest struct { LocalPath string `json:"local_path"` Provider string `json:"provider"` ProviderRepoID string `json:"provider_repo_id"` + ProviderHost string `json:"provider_host"` ProviderOwner string `json:"provider_owner"` ProviderName string `json:"provider_name"` DefaultBranch string `json:"default_branch"` @@ -295,6 +296,7 @@ func (h *RepositoryHandlers) httpCreateRepository(c *gin.Context) { LocalPath: body.LocalPath, Provider: body.Provider, ProviderRepoID: body.ProviderRepoID, + ProviderHost: body.ProviderHost, ProviderOwner: body.ProviderOwner, ProviderName: body.ProviderName, DefaultBranch: body.DefaultBranch, @@ -383,6 +385,7 @@ type httpUpdateRepositoryRequest struct { LocalPath *string `json:"local_path"` Provider *string `json:"provider"` ProviderRepoID *string `json:"provider_repo_id"` + ProviderHost *string `json:"provider_host"` ProviderOwner *string `json:"provider_owner"` ProviderName *string `json:"provider_name"` DefaultBranch *string `json:"default_branch"` @@ -407,6 +410,7 @@ func (h *RepositoryHandlers) httpUpdateRepository(c *gin.Context) { LocalPath: body.LocalPath, Provider: body.Provider, ProviderRepoID: body.ProviderRepoID, + ProviderHost: body.ProviderHost, ProviderOwner: body.ProviderOwner, ProviderName: body.ProviderName, DefaultBranch: body.DefaultBranch, @@ -485,6 +489,7 @@ type wsCreateRepositoryRequest struct { LocalPath string `json:"local_path"` Provider string `json:"provider"` ProviderRepoID string `json:"provider_repo_id"` + ProviderHost string `json:"provider_host"` ProviderOwner string `json:"provider_owner"` ProviderName string `json:"provider_name"` DefaultBranch string `json:"default_branch"` @@ -511,6 +516,7 @@ func (h *RepositoryHandlers) wsCreateRepository(ctx context.Context, msg *ws.Mes LocalPath: req.LocalPath, Provider: req.Provider, ProviderRepoID: req.ProviderRepoID, + ProviderHost: req.ProviderHost, ProviderOwner: req.ProviderOwner, ProviderName: req.ProviderName, DefaultBranch: req.DefaultBranch, @@ -557,6 +563,7 @@ type wsUpdateRepositoryRequest struct { LocalPath *string `json:"local_path,omitempty"` Provider *string `json:"provider,omitempty"` ProviderRepoID *string `json:"provider_repo_id,omitempty"` + ProviderHost *string `json:"provider_host,omitempty"` ProviderOwner *string `json:"provider_owner,omitempty"` ProviderName *string `json:"provider_name,omitempty"` DefaultBranch *string `json:"default_branch,omitempty"` @@ -582,6 +589,7 @@ func (h *RepositoryHandlers) wsUpdateRepository(ctx context.Context, msg *ws.Mes LocalPath: req.LocalPath, Provider: req.Provider, ProviderRepoID: req.ProviderRepoID, + ProviderHost: req.ProviderHost, ProviderOwner: req.ProviderOwner, ProviderName: req.ProviderName, DefaultBranch: req.DefaultBranch, diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index be929e923c..d188547bae 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -899,6 +899,7 @@ type Repository struct { // Provider fields describe the upstream source (e.g. github/gitlab) for future syncing. Provider string `json:"provider"` ProviderRepoID string `json:"provider_repo_id"` + ProviderHost string `json:"provider_host"` ProviderOwner string `json:"provider_owner"` ProviderName string `json:"provider_name"` RemoteURL string `json:"remote_url"` diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index 16d7b270d5..67ccd9279e 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -262,7 +262,7 @@ type RepositoryEntityRepository interface { DeleteRepositoryScript(ctx context.Context, id string) error ListRepositoryScripts(ctx context.Context, repositoryID string) ([]*models.RepositoryScript, error) ListScriptsByRepositoryIDs(ctx context.Context, repoIDs []string) (map[string][]*models.RepositoryScript, error) - GetRepositoryByProviderInfo(ctx context.Context, workspaceID, provider, owner, name string) (*models.Repository, error) + GetRepositoryByProviderInfo(ctx context.Context, workspaceID, provider, host, owner, name string) (*models.Repository, error) } // ExecutorRepository handles executor CRUD, executor profiles, and running state. diff --git a/apps/backend/internal/task/repository/sqlite/base_migrations.go b/apps/backend/internal/task/repository/sqlite/base_migrations.go index f1da8a94a0..b1844db0cf 100644 --- a/apps/backend/internal/task/repository/sqlite/base_migrations.go +++ b/apps/backend/internal/task/repository/sqlite/base_migrations.go @@ -104,6 +104,12 @@ func (r *Repository) runMigrations() error { r.migrate.Apply("task_sessions.name", `ALTER TABLE task_sessions ADD COLUMN name TEXT DEFAULT ''`) r.migrate.Apply("repositories.copy_files", `ALTER TABLE repositories ADD COLUMN copy_files TEXT DEFAULT ''`) r.migrate.Apply("repositories.remote_url", `ALTER TABLE repositories ADD COLUMN remote_url TEXT DEFAULT ''`) + r.migrate.Apply("repositories.provider_host", `ALTER TABLE repositories ADD COLUMN provider_host TEXT DEFAULT ''`) + r.migrate.Apply("repositories.provider_host.github_backfill", ` + UPDATE repositories + SET provider_host = 'https://github.com' + WHERE LOWER(TRIM(provider)) = 'github' + AND TRIM(COALESCE(provider_host, '')) = ''`) r.migrate.Apply("repositories.worktree_branch_template", `ALTER TABLE repositories ADD COLUMN worktree_branch_template TEXT DEFAULT 'feature/{title}-{suffix}'`) r.migrate.Apply("repositories.worktree_branch_template.backfill", `UPDATE repositories SET worktree_branch_template = COALESCE(NULLIF(TRIM(worktree_branch_prefix), ''), 'feature/') || '{title}-{suffix}'`) r.migrate.Apply("task_plans.implementation_started_at", `ALTER TABLE task_plans ADD COLUMN implementation_started_at TIMESTAMP`) diff --git a/apps/backend/internal/task/repository/sqlite/base_schema.go b/apps/backend/internal/task/repository/sqlite/base_schema.go index 493fb36c08..423c881a23 100644 --- a/apps/backend/internal/task/repository/sqlite/base_schema.go +++ b/apps/backend/internal/task/repository/sqlite/base_schema.go @@ -289,6 +289,7 @@ func (r *Repository) initTaskSchema() error { local_path TEXT DEFAULT '', provider TEXT DEFAULT '', provider_repo_id TEXT DEFAULT '', + provider_host TEXT DEFAULT '', provider_owner TEXT DEFAULT '', provider_name TEXT DEFAULT '', remote_url TEXT DEFAULT '', diff --git a/apps/backend/internal/task/repository/sqlite/repository_entity.go b/apps/backend/internal/task/repository/sqlite/repository_entity.go index 68c99172b1..7960c713d0 100644 --- a/apps/backend/internal/task/repository/sqlite/repository_entity.go +++ b/apps/backend/internal/task/repository/sqlite/repository_entity.go @@ -25,11 +25,11 @@ func (r *Repository) CreateRepository(ctx context.Context, repository *models.Re _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO repositories ( - id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, + id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_host, provider_owner, provider_name, remote_url, default_branch, worktree_branch_prefix, worktree_branch_template, pull_before_worktree, setup_script, cleanup_script, dev_script, copy_files, created_at, updated_at, deleted_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), repository.ID, repository.WorkspaceID, repository.Name, repository.SourceType, repository.LocalPath, repository.Provider, - repository.ProviderRepoID, repository.ProviderOwner, repository.ProviderName, repository.RemoteURL, repository.DefaultBranch, repository.WorktreeBranchPrefix, + repository.ProviderRepoID, repository.ProviderHost, repository.ProviderOwner, repository.ProviderName, repository.RemoteURL, repository.DefaultBranch, repository.WorktreeBranchPrefix, repository.WorktreeBranchTemplate, dialect.BoolToInt(repository.PullBeforeWorktree), repository.SetupScript, repository.CleanupScript, repository.DevScript, repository.CopyFiles, repository.CreatedAt, repository.UpdatedAt, repository.DeletedAt) return err @@ -40,12 +40,12 @@ func (r *Repository) GetRepository(ctx context.Context, id string) (*models.Repo repository := &models.Repository{} err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` - SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, + SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_host, provider_owner, provider_name, remote_url, default_branch, worktree_branch_prefix, worktree_branch_template, pull_before_worktree, setup_script, cleanup_script, dev_script, copy_files, created_at, updated_at, deleted_at FROM repositories WHERE id = ? AND deleted_at IS NULL `), id).Scan( &repository.ID, &repository.WorkspaceID, &repository.Name, &repository.SourceType, &repository.LocalPath, - &repository.Provider, &repository.ProviderRepoID, &repository.ProviderOwner, &repository.ProviderName, &repository.RemoteURL, + &repository.Provider, &repository.ProviderRepoID, &repository.ProviderHost, &repository.ProviderOwner, &repository.ProviderName, &repository.RemoteURL, &repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.WorktreeBranchTemplate, &repository.PullBeforeWorktree, &repository.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CopyFiles, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, ) @@ -61,11 +61,11 @@ func (r *Repository) UpdateRepository(ctx context.Context, repository *models.Re result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE repositories SET - name = ?, source_type = ?, local_path = ?, provider = ?, provider_repo_id = ?, provider_owner = ?, + name = ?, source_type = ?, local_path = ?, provider = ?, provider_repo_id = ?, provider_host = ?, provider_owner = ?, provider_name = ?, remote_url = ?, default_branch = ?, worktree_branch_prefix = ?, worktree_branch_template = ?, pull_before_worktree = ?, setup_script = ?, cleanup_script = ?, dev_script = ?, copy_files = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL `), repository.Name, repository.SourceType, repository.LocalPath, repository.Provider, repository.ProviderRepoID, - repository.ProviderOwner, repository.ProviderName, repository.RemoteURL, repository.DefaultBranch, repository.WorktreeBranchPrefix, repository.WorktreeBranchTemplate, dialect.BoolToInt(repository.PullBeforeWorktree), + repository.ProviderHost, repository.ProviderOwner, repository.ProviderName, repository.RemoteURL, repository.DefaultBranch, repository.WorktreeBranchPrefix, repository.WorktreeBranchTemplate, dialect.BoolToInt(repository.PullBeforeWorktree), repository.SetupScript, repository.CleanupScript, repository.DevScript, repository.CopyFiles, repository.UpdatedAt, repository.ID) if err != nil { return err @@ -128,7 +128,7 @@ func (r *Repository) DeleteRepositoryIfNoActiveTaskSessions(ctx context.Context, // ListRepositories returns all repositories for a workspace func (r *Repository) ListRepositories(ctx context.Context, workspaceID string) ([]*models.Repository, error) { rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` - SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, + SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_host, provider_owner, provider_name, remote_url, default_branch, worktree_branch_prefix, worktree_branch_template, pull_before_worktree, setup_script, cleanup_script, dev_script, copy_files, created_at, updated_at, deleted_at FROM repositories WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY created_at DESC `), workspaceID) @@ -142,7 +142,7 @@ func (r *Repository) ListRepositories(ctx context.Context, workspaceID string) ( repository := &models.Repository{} err := rows.Scan( &repository.ID, &repository.WorkspaceID, &repository.Name, &repository.SourceType, &repository.LocalPath, - &repository.Provider, &repository.ProviderRepoID, &repository.ProviderOwner, &repository.ProviderName, &repository.RemoteURL, + &repository.Provider, &repository.ProviderRepoID, &repository.ProviderHost, &repository.ProviderOwner, &repository.ProviderName, &repository.RemoteURL, &repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.WorktreeBranchTemplate, &repository.PullBeforeWorktree, &repository.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CopyFiles, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, ) if err != nil { @@ -155,16 +155,17 @@ func (r *Repository) ListRepositories(ctx context.Context, workspaceID string) ( // GetRepositoryByProviderInfo finds a repository by workspace, provider, owner, and name. // Returns nil, nil if not found. -func (r *Repository) GetRepositoryByProviderInfo(ctx context.Context, workspaceID, provider, owner, name string) (*models.Repository, error) { +func (r *Repository) GetRepositoryByProviderInfo(ctx context.Context, workspaceID, provider, host, owner, name string) (*models.Repository, error) { repository := &models.Repository{} err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` - SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, + SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_host, provider_owner, provider_name, remote_url, default_branch, worktree_branch_prefix, worktree_branch_template, pull_before_worktree, setup_script, cleanup_script, dev_script, copy_files, created_at, updated_at, deleted_at FROM repositories - WHERE workspace_id = ? AND provider = ? AND provider_owner = ? AND provider_name = ? AND deleted_at IS NULL - `), workspaceID, provider, owner, name).Scan( + WHERE workspace_id = ? AND provider = ? AND provider_host = ? + AND provider_owner = ? AND provider_name = ? AND deleted_at IS NULL + `), workspaceID, provider, host, owner, name).Scan( &repository.ID, &repository.WorkspaceID, &repository.Name, &repository.SourceType, &repository.LocalPath, - &repository.Provider, &repository.ProviderRepoID, &repository.ProviderOwner, &repository.ProviderName, &repository.RemoteURL, + &repository.Provider, &repository.ProviderRepoID, &repository.ProviderHost, &repository.ProviderOwner, &repository.ProviderName, &repository.RemoteURL, &repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.WorktreeBranchTemplate, &repository.PullBeforeWorktree, &repository.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CopyFiles, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, ) if err == sql.ErrNoRows { diff --git a/apps/backend/internal/task/repository/sqlite/repository_entity_test.go b/apps/backend/internal/task/repository/sqlite/repository_entity_test.go index 7f2eefc683..6b2bca2937 100644 --- a/apps/backend/internal/task/repository/sqlite/repository_entity_test.go +++ b/apps/backend/internal/task/repository/sqlite/repository_entity_test.go @@ -72,6 +72,85 @@ func TestRepositoryCopyFiles_RoundTrip(t *testing.T) { } } +func TestRepositoryProviderHost_RoundTrip(t *testing.T) { + repo := newRepoForEntityTests(t) + ctx := context.Background() + seedWorkspace(t, repo, "ws-provider-host") + in := &models.Repository{ + ID: "repo-gitlab", WorkspaceID: "ws-provider-host", Name: "group/subgroup/project", + SourceType: "provider", Provider: "gitlab", ProviderHost: "http://gitlab.internal:8080", + ProviderOwner: "group/subgroup", ProviderName: "project", + } + if err := repo.CreateRepository(ctx, in); err != nil { + t.Fatalf("create repository: %v", err) + } + got, err := repo.GetRepository(ctx, in.ID) + if err != nil { + t.Fatalf("get repository: %v", err) + } + if got.ProviderHost != in.ProviderHost { + t.Fatalf("provider_host = %q, want %q", got.ProviderHost, in.ProviderHost) + } + got.ProviderHost = "https://gitlab.internal" + if err := repo.UpdateRepository(ctx, got); err != nil { + t.Fatalf("update repository: %v", err) + } + updated, err := repo.GetRepository(ctx, in.ID) + if err != nil || updated.ProviderHost != "https://gitlab.internal" { + t.Fatalf("updated provider_host = %q, err = %v", updated.ProviderHost, err) + } +} + +func TestGetRepositoryByProviderInfoSeparatesGitLabHosts(t *testing.T) { + repo := newRepoForEntityTests(t) + ctx := context.Background() + seedWorkspace(t, repo, "ws-host-collision") + for _, item := range []*models.Repository{ + {ID: "repo-public", WorkspaceID: "ws-host-collision", Name: "public", SourceType: "provider", Provider: "gitlab", ProviderHost: "https://gitlab.com", ProviderOwner: "group/subgroup", ProviderName: "project"}, + {ID: "repo-private", WorkspaceID: "ws-host-collision", Name: "private", SourceType: "provider", Provider: "gitlab", ProviderHost: "https://gitlab.internal", ProviderOwner: "group/subgroup", ProviderName: "project"}, + } { + if err := repo.CreateRepository(ctx, item); err != nil { + t.Fatalf("create repository %s: %v", item.ID, err) + } + } + got, err := repo.GetRepositoryByProviderInfo( + ctx, "ws-host-collision", "gitlab", "https://gitlab.internal", "group/subgroup", "project", + ) + if err != nil || got == nil || got.ID != "repo-private" { + t.Fatalf("host-aware lookup = %+v, err = %v; want repo-private", got, err) + } +} + +func TestRepositoryProviderHostMigrationBackfillsOnlyUnambiguousGitHubRows(t *testing.T) { + repo := newRepoForEntityTests(t) + ctx := context.Background() + seedWorkspace(t, repo, "ws-provider-upgrade") + for _, item := range []*models.Repository{ + {ID: "legacy-github", WorkspaceID: "ws-provider-upgrade", Name: "org/repo", SourceType: "provider", Provider: "github", ProviderOwner: "org", ProviderName: "repo"}, + {ID: "legacy-gitlab", WorkspaceID: "ws-provider-upgrade", Name: "group/repo", SourceType: "provider", Provider: "gitlab", ProviderOwner: "group", ProviderName: "repo"}, + } { + if err := repo.CreateRepository(ctx, item); err != nil { + t.Fatalf("create legacy repository %s: %v", item.ID, err) + } + } + + if err := repo.initSchema(); err != nil { + t.Fatalf("first upgrade replay: %v", err) + } + if err := repo.initSchema(); err != nil { + t.Fatalf("second upgrade replay: %v", err) + } + + githubRepo, err := repo.GetRepository(ctx, "legacy-github") + if err != nil || githubRepo.ProviderHost != "https://github.com" { + t.Fatalf("GitHub provider_host = %q, err = %v", githubRepo.ProviderHost, err) + } + gitlabRepo, err := repo.GetRepository(ctx, "legacy-gitlab") + if err != nil || gitlabRepo.ProviderHost != "" { + t.Fatalf("GitLab provider_host = %q, err = %v; want unknown", gitlabRepo.ProviderHost, err) + } +} + func TestGetRepositoryReturnsNotFoundError(t *testing.T) { repo := newRepoForEntityTests(t) _, err := repo.GetRepository(context.Background(), "missing") diff --git a/apps/backend/internal/task/service/remote_repository_test.go b/apps/backend/internal/task/service/remote_repository_test.go index 131d986d4c..3d08419ef3 100644 --- a/apps/backend/internal/task/service/remote_repository_test.go +++ b/apps/backend/internal/task/service/remote_repository_test.go @@ -21,7 +21,7 @@ func TestParseRemoteRepositoryURL(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - provider, owner, repo, canonical, err := parseRemoteRepositoryURL(tc.raw) + provider, owner, repo, canonical, err := parseRemoteRepositoryURL(tc.raw, "") if err != nil { t.Fatal(err) } @@ -34,7 +34,26 @@ func TestParseRemoteRepositoryURL(t *testing.T) { func TestParseRemoteRepositoryURLRejectsUnsupportedHost(t *testing.T) { t.Parallel() - if _, _, _, _, err := parseRemoteRepositoryURL("https://example.com/acme/api"); err == nil { + if _, _, _, _, err := parseRemoteRepositoryURL("https://example.com/acme/api", ""); err == nil { t.Fatal("expected unsupported host error") } } + +func TestParseRemoteRepositoryURLAcceptsProviderHintForSelfManagedGitLab(t *testing.T) { + t.Parallel() + + provider, owner, repo, canonical, err := parseRemoteRepositoryURL( + "https://gitlab.internal:8443/acme/platform/api", + "gitlab", + ) + if err != nil { + t.Fatal(err) + } + if provider != "gitlab" || owner != "acme/platform" || repo != "api" || + canonical != "https://gitlab.internal:8443/acme/platform/api.git" { + t.Fatalf("got (%q, %q, %q, %q)", provider, owner, repo, canonical) + } + if host := remoteProviderHost(provider, canonical); host != "https://gitlab.internal:8443" { + t.Fatalf("provider host = %q", host) + } +} diff --git a/apps/backend/internal/task/service/repository_discovery.go b/apps/backend/internal/task/service/repository_discovery.go index c471bfeeed..1150f1652f 100644 --- a/apps/backend/internal/task/service/repository_discovery.go +++ b/apps/backend/internal/task/service/repository_discovery.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io/fs" + "net/url" "os" "os/exec" "path/filepath" @@ -20,6 +21,11 @@ type RepositoryDiscoveryConfig struct { TaskWorktreeRoots []string } +const ( + githubProviderName = "github" + githubProviderHost = "https://github.com" +) + // LocalRepoStatus reports the current branch and dirty file paths for a // local repository on disk. Used by the task-create dialog to preflight the // fresh-branch flow. @@ -900,55 +906,72 @@ func parseGitConfigOriginURL(config string) string { return "" } -// ParseGitRemoteURL extracts provider, owner, and repo name from a git remote URL. -// Supports HTTPS (https://github.com/owner/repo.git), SSH (git@github.com:owner/repo.git), -// and ssh:// (ssh://git@github.com/owner/repo.git) formats. -// Returns empty strings for unrecognized URLs or non-GitHub providers. -func ParseGitRemoteURL(remoteURL string) (provider, owner, name string) { +// ParseGitRemoteIdentity returns a normalized provider origin and full project +// path. HTTP(S) origins preserve their scheme; SSH remotes use ssh://host. +func ParseGitRemoteIdentity(remoteURL string) (origin, projectPath string) { remoteURL = strings.TrimSpace(remoteURL) if remoteURL == "" { - return "", "", "" + return "", "" } - - host, path := splitRemoteURL(remoteURL) - if host == "" || path == "" { - return "", "", "" + if strings.Contains(remoteURL, "@") && !strings.Contains(remoteURL, "://") { + _, remoteURL, _ = strings.Cut(remoteURL, "@") + host, path, ok := strings.Cut(remoteURL, ":") + if !ok { + return "", "" + } + return normalizeRemoteIdentity("ssh", host, path) } - - if !strings.Contains(strings.ToLower(host), "github.com") { - return "", "", "" + parsed, err := url.Parse(remoteURL) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "", "" } - - path = strings.TrimSuffix(path, ".git") - path = strings.Trim(path, "/") - parts := strings.SplitN(path, "/", 3) - if len(parts) < 2 || parts[0] == "" || parts[1] == "" { - return "", "", "" - } - return "github", parts[0], parts[1] + return normalizeRemoteIdentity(parsed.Scheme, parsed.Host, parsed.Path) } -// splitRemoteURL splits a git remote URL into host and path components. -func splitRemoteURL(remoteURL string) (host, path string) { - switch { - case strings.HasPrefix(remoteURL, "https://") || strings.HasPrefix(remoteURL, "http://"): - trimmed := strings.TrimPrefix(remoteURL, "https://") - trimmed = strings.TrimPrefix(trimmed, "http://") - host, path, _ = strings.Cut(trimmed, "/") - - case strings.HasPrefix(remoteURL, "ssh://"): - trimmed := strings.TrimPrefix(remoteURL, "ssh://") - if _, after, ok := strings.Cut(trimmed, "@"); ok { - trimmed = after - } - host, path, _ = strings.Cut(trimmed, "/") - - case strings.Contains(remoteURL, "@") && strings.Contains(remoteURL, ":"): - // git@github.com:owner/repo.git - _, afterAt, _ := strings.Cut(remoteURL, "@") - host, path, _ = strings.Cut(afterAt, ":") +func normalizeRemoteIdentity(scheme, host, path string) (string, string) { + scheme = strings.ToLower(strings.TrimSpace(scheme)) + host = strings.ToLower(strings.TrimSpace(host)) + if scheme != "http" && scheme != "https" && scheme != "ssh" { + return "", "" } - return host, path + path = strings.TrimSuffix(strings.Trim(strings.TrimSpace(path), "/"), ".git") + if host == "" || path == "" || !strings.Contains(path, "/") { + return "", "" + } + return scheme + "://" + host, path +} + +// ParseGitRemoteURL retains the legacy GitHub-only provider API. +func ParseGitRemoteURL(remoteURL string) (provider, owner, name string) { + origin, projectPath := ParseGitRemoteIdentity(remoteURL) + parsed, err := url.Parse(origin) + if err != nil || !strings.EqualFold(parsed.Hostname(), "github.com") { + return "", "", "" + } + owner, name = splitProjectPath(projectPath) + if owner == "" { + return "", "", "" + } + return githubProviderName, owner, name +} + +func splitProjectPath(projectPath string) (owner, name string) { + lastSlash := strings.LastIndex(projectPath, "/") + if lastSlash <= 0 || lastSlash == len(projectPath)-1 { + return "", "" + } + return projectPath[:lastSlash], projectPath[lastSlash+1:] +} + +// ResolveGitRemoteIdentity reads and parses a local checkout's origin. +func ResolveGitRemoteIdentity(repoPath string) (origin, owner, name string) { + remoteURL, err := readGitRemoteOriginURL(repoPath) + if err != nil { + return "", "", "" + } + origin, projectPath := ParseGitRemoteIdentity(remoteURL) + owner, name = splitProjectPath(projectPath) + return origin, owner, name } // ResolveGitRemoteProvider detects the provider, owner, and repo name from a @@ -961,6 +984,24 @@ func ResolveGitRemoteProvider(repoPath string) (provider, owner, name string) { return ParseGitRemoteURL(url) } +// ResolveGitRemoteProviderIdentity includes the durable normalized origin. +func ResolveGitRemoteProviderIdentity(repoPath string) (provider, origin, owner, name string) { + origin, owner, name = ResolveGitRemoteIdentity(repoPath) + parsed, err := url.Parse(origin) + if err != nil { + return "", "", "", "" + } + switch strings.ToLower(parsed.Hostname()) { + case "github.com": + provider = githubProviderName + origin = githubProviderHost + case "gitlab.com": + provider = "gitlab" + origin = "https://gitlab.com" + } + return provider, origin, owner, name +} + func parsePackedRefs(refsRoot string, branchMap map[string]Branch) { content, err := os.ReadFile(filepath.Join(refsRoot, "packed-refs")) if err != nil { diff --git a/apps/backend/internal/task/service/repository_discovery_test.go b/apps/backend/internal/task/service/repository_discovery_test.go index 3d362990a6..ae8f3d3bd7 100644 --- a/apps/backend/internal/task/service/repository_discovery_test.go +++ b/apps/backend/internal/task/service/repository_discovery_test.go @@ -539,6 +539,29 @@ func TestParseGitRemoteURL(t *testing.T) { } } +func TestParseGitRemoteIdentityPreservesOriginAndFullSubgroupPath(t *testing.T) { + tests := []struct { + name string + remote string + wantOrigin string + wantProject string + }{ + {"self-managed HTTP", "http://GitLab.Internal:8080/group/subgroup/project.git", "http://gitlab.internal:8080", "group/subgroup/project"}, + {"self-managed HTTPS", "https://gitlab.internal/group/subgroup/project", "https://gitlab.internal", "group/subgroup/project"}, + {"SSH URL", "ssh://git@gitlab.internal/group/subgroup/project.git", "ssh://gitlab.internal", "group/subgroup/project"}, + {"SCP SSH", "git@gitlab.internal:group/subgroup/project.git", "ssh://gitlab.internal", "group/subgroup/project"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + origin, project := ParseGitRemoteIdentity(tt.remote) + if origin != tt.wantOrigin || project != tt.wantProject { + t.Fatalf("ParseGitRemoteIdentity(%q) = (%q, %q), want (%q, %q)", + tt.remote, origin, project, tt.wantOrigin, tt.wantProject) + } + }) + } +} + func TestParseGitConfigOriginURL(t *testing.T) { config := `[core] repositoryformatversion = 0 diff --git a/apps/backend/internal/task/service/service_branches_qa_test.go b/apps/backend/internal/task/service/service_branches_qa_test.go index 8a8ff18b8d..7fc8610f56 100644 --- a/apps/backend/internal/task/service/service_branches_qa_test.go +++ b/apps/backend/internal/task/service/service_branches_qa_test.go @@ -99,7 +99,7 @@ func TestAddBranchToTask_QA_RegisteredProviderRepoSkipsProbe(t *testing.T) { _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "WF"}) _ = repo.CreateRepository(ctx, &models.Repository{ ID: "repo-existing", WorkspaceID: "ws-1", Name: "acme/widgets", - Provider: "github", ProviderOwner: "acme", ProviderName: "widgets", + Provider: "github", ProviderHost: "https://github.com", ProviderOwner: "acme", ProviderName: "widgets", DefaultBranch: "trunk", SourceType: "provider", }) _ = repo.CreateRepository(ctx, &models.Repository{ID: "repo-1", WorkspaceID: "ws-1", Name: "frontend", DefaultBranch: "main"}) @@ -148,7 +148,7 @@ func TestAddBranchToTask_QA_BackfillsExistingEmptyDefaultBranch(t *testing.T) { _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "WF"}) _ = repo.CreateRepository(ctx, &models.Repository{ ID: "repo-empty", WorkspaceID: "ws-1", Name: "acme/widgets", - Provider: "github", ProviderOwner: "acme", ProviderName: "widgets", + Provider: "github", ProviderHost: "https://github.com", ProviderOwner: "acme", ProviderName: "widgets", DefaultBranch: "", SourceType: "provider", // legacy orphan with empty default }) _ = repo.CreateRepository(ctx, &models.Repository{ID: "repo-1", WorkspaceID: "ws-1", Name: "frontend", DefaultBranch: "main"}) diff --git a/apps/backend/internal/task/service/service_branches_test.go b/apps/backend/internal/task/service/service_branches_test.go index 4117dbc7db..0048480946 100644 --- a/apps/backend/internal/task/service/service_branches_test.go +++ b/apps/backend/internal/task/service/service_branches_test.go @@ -455,7 +455,7 @@ func TestAddBranchToTask_ResolvesGitHubURL(t *testing.T) { // returns the existing row instead of creating a duplicate. _ = repo.CreateRepository(ctx, &models.Repository{ ID: "repo-target", WorkspaceID: "ws-1", Name: "acme/widgets", - Provider: "github", ProviderOwner: "acme", ProviderName: "widgets", + Provider: "github", ProviderHost: "https://github.com", ProviderOwner: "acme", ProviderName: "widgets", DefaultBranch: "main", }) _ = repo.CreateRepository(ctx, &models.Repository{ diff --git a/apps/backend/internal/task/service/service_events.go b/apps/backend/internal/task/service/service_events.go index 4c88150889..015f3ec5c6 100644 --- a/apps/backend/internal/task/service/service_events.go +++ b/apps/backend/internal/task/service/service_events.go @@ -446,6 +446,7 @@ func (s *Service) publishRepositoryEvent(ctx context.Context, eventType string, "local_path": repository.LocalPath, "provider": repository.Provider, "provider_repo_id": repository.ProviderRepoID, + "provider_host": repository.ProviderHost, "provider_owner": repository.ProviderOwner, "provider_name": repository.ProviderName, "default_branch": repository.DefaultBranch, diff --git a/apps/backend/internal/task/service/service_requests.go b/apps/backend/internal/task/service/service_requests.go index 6eb72ff80b..532e7e9a5d 100644 --- a/apps/backend/internal/task/service/service_requests.go +++ b/apps/backend/internal/task/service/service_requests.go @@ -113,6 +113,7 @@ type UpdateWorkspaceRequest struct { type FindOrCreateRepositoryRequest struct { WorkspaceID string `json:"workspace_id"` Provider string `json:"provider"` + ProviderHost string `json:"provider_host"` ProviderOwner string `json:"provider_owner"` ProviderName string `json:"provider_name"` ProviderRepoID string `json:"provider_repo_id"` @@ -129,6 +130,7 @@ type CreateRepositoryRequest struct { LocalPath string `json:"local_path"` Provider string `json:"provider"` ProviderRepoID string `json:"provider_repo_id"` + ProviderHost string `json:"provider_host"` ProviderOwner string `json:"provider_owner"` ProviderName string `json:"provider_name"` RemoteURL string `json:"remote_url"` @@ -149,6 +151,7 @@ type UpdateRepositoryRequest struct { LocalPath *string `json:"local_path,omitempty"` Provider *string `json:"provider,omitempty"` ProviderRepoID *string `json:"provider_repo_id,omitempty"` + ProviderHost *string `json:"provider_host,omitempty"` ProviderOwner *string `json:"provider_owner,omitempty"` ProviderName *string `json:"provider_name,omitempty"` DefaultBranch *string `json:"default_branch,omitempty"` diff --git a/apps/backend/internal/task/service/service_resources.go b/apps/backend/internal/task/service/service_resources.go index 95ac45c081..ad082a2f9b 100644 --- a/apps/backend/internal/task/service/service_resources.go +++ b/apps/backend/internal/task/service/service_resources.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "net/url" "os" "strings" "sync" @@ -27,6 +28,28 @@ const ( var ErrWorkspaceConfirmNameMismatch = errors.New("confirm_name does not match workspace name") +func normalizeProviderHost(provider, raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + if strings.EqualFold(strings.TrimSpace(provider), githubProviderName) { + return githubProviderHost + } + return "" + } + if !strings.Contains(raw, "://") { + raw = "https://" + raw + } + parsed, err := url.Parse(raw) + if err != nil || parsed.Host == "" || parsed.User != nil { + return "" + } + scheme := strings.ToLower(parsed.Scheme) + if scheme != "http" && scheme != "https" { + return "" + } + return scheme + "://" + strings.ToLower(parsed.Host) +} + type workspaceDeleteTaskCleanup struct { task *models.Task sessions []*models.TaskSession @@ -615,6 +638,7 @@ func (s *Service) CreateRepository(ctx context.Context, req *CreateRepositoryReq LocalPath: localPath, Provider: req.Provider, ProviderRepoID: req.ProviderRepoID, + ProviderHost: normalizeProviderHost(req.Provider, req.ProviderHost), ProviderOwner: req.ProviderOwner, ProviderName: req.ProviderName, RemoteURL: req.RemoteURL, @@ -628,10 +652,14 @@ func (s *Service) CreateRepository(ctx context.Context, req *CreateRepositoryReq CopyFiles: req.CopyFiles, } - // Auto-detect GitHub provider info from git remote if not provided - if repository.Provider == "" && repository.LocalPath != "" { - if p, o, n := ResolveGitRemoteProvider(repository.LocalPath); p != "" { + // Auto-detect provider identity from the origin when it is available. + if repository.LocalPath != "" && (repository.Provider == "" || repository.ProviderHost == "") { + p, h, o, n := ResolveGitRemoteProviderIdentity(repository.LocalPath) + if repository.Provider == "" { repository.Provider = p + } + if repository.Provider != "" && (strings.HasPrefix(h, "http://") || strings.HasPrefix(h, "https://")) { + repository.ProviderHost = h repository.ProviderOwner = o repository.ProviderName = n } @@ -653,8 +681,8 @@ func (s *Service) GetRepository(ctx context.Context, id string) (*models.Reposit // GetRepositoryByProviderInfo looks up a repository by workspace and provider identity. // Returns nil (with nil error) when no matching repository exists. -func (s *Service) GetRepositoryByProviderInfo(ctx context.Context, workspaceID, provider, owner, name string) (*models.Repository, error) { - return s.repoEntities.GetRepositoryByProviderInfo(ctx, workspaceID, provider, owner, name) +func (s *Service) GetRepositoryByProviderInfo(ctx context.Context, workspaceID, provider, host, owner, name string) (*models.Repository, error) { + return s.repoEntities.GetRepositoryByProviderInfo(ctx, workspaceID, provider, normalizeProviderHost(provider, host), owner, name) } // FindOrCreateRepository looks up a repository by provider info, creating one if not found. @@ -667,7 +695,10 @@ func (s *Service) GetRepositoryByProviderInfo(ctx context.Context, workspaceID, // create race between snapshot and lookup, so a snapshot-miss does NOT // mean this call created the row. func (s *Service) FindOrCreateRepository(ctx context.Context, req *FindOrCreateRepositoryRequest) (*models.Repository, bool, error) { - existing, err := s.repoEntities.GetRepositoryByProviderInfo(ctx, req.WorkspaceID, req.Provider, req.ProviderOwner, req.ProviderName) + req.ProviderHost = normalizeProviderHost(req.Provider, req.ProviderHost) + existing, err := s.repoEntities.GetRepositoryByProviderInfo( + ctx, req.WorkspaceID, req.Provider, req.ProviderHost, req.ProviderOwner, req.ProviderName, + ) if err != nil { return nil, false, fmt.Errorf("lookup repository: %w", err) } @@ -686,6 +717,10 @@ func (s *Service) FindOrCreateRepository(ctx context.Context, req *FindOrCreateR existing.LocalPath = localPath dirty = true } + if existing.ProviderHost == "" && req.ProviderHost != "" { + existing.ProviderHost = normalizeProviderHost(req.Provider, req.ProviderHost) + dirty = true + } // Backfill default_branch when the caller carries one and the existing // row is still empty. Lets the synchronous add_branch probe persist its // answer onto a previously-empty Repository row (e.g. one created by @@ -719,6 +754,7 @@ func (s *Service) FindOrCreateRepository(ctx context.Context, req *FindOrCreateR LocalPath: req.LocalPath, Provider: req.Provider, ProviderRepoID: req.ProviderRepoID, + ProviderHost: req.ProviderHost, ProviderOwner: req.ProviderOwner, ProviderName: req.ProviderName, RemoteURL: req.RemoteURL, @@ -787,6 +823,9 @@ func applyRepositoryUpdates(repository *models.Repository, req *UpdateRepository if req.ProviderRepoID != nil { repository.ProviderRepoID = *req.ProviderRepoID } + if req.ProviderHost != nil { + repository.ProviderHost = normalizeProviderHost(repository.Provider, *req.ProviderHost) + } if req.ProviderOwner != nil { repository.ProviderOwner = *req.ProviderOwner } diff --git a/apps/backend/internal/task/service/service_resources_test.go b/apps/backend/internal/task/service/service_resources_test.go index 484e17a927..b99fe31759 100644 --- a/apps/backend/internal/task/service/service_resources_test.go +++ b/apps/backend/internal/task/service/service_resources_test.go @@ -208,6 +208,44 @@ func TestService_CreateRepositoryAllowsPathlessProvider(t *testing.T) { if created.LocalPath != "" { t.Fatalf("LocalPath = %q, want empty", created.LocalPath) } + if created.ProviderHost != "https://github.com" { + t.Fatalf("ProviderHost = %q, want GitHub default", created.ProviderHost) + } +} + +func TestService_FindOrCreateRepositoryMatchesGitHubRepositoryWithoutExplicitHost(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + repoPath := filepath.Join(t.TempDir(), "github-repo") + makeRepo(t, repoPath) + created, err := svc.CreateRepository(ctx, &CreateRepositoryRequest{ + WorkspaceID: "ws-1", + Name: "owner/repo", + LocalPath: repoPath, + Provider: "github", + ProviderOwner: "owner", + ProviderName: "repo", + }) + if err != nil { + t.Fatalf("CreateRepository: %v", err) + } + + resolved, wasCreated, err := svc.FindOrCreateRepository(ctx, &FindOrCreateRepositoryRequest{ + WorkspaceID: "ws-1", + Provider: "github", + ProviderHost: "https://github.com", + ProviderOwner: "owner", + ProviderName: "repo", + }) + if err != nil { + t.Fatalf("FindOrCreateRepository: %v", err) + } + if wasCreated || resolved.ID != created.ID { + t.Fatalf("resolved repository = %q (created=%t), want existing %q", resolved.ID, wasCreated, created.ID) + } } func TestService_FindOrCreateRepositoryRejectsInvalidLocalPathBackfill(t *testing.T) { diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index d306814192..6bc6521590 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -29,6 +29,9 @@ const defaultPriority = "medium" const ( providerAzureDevOps = "azure_devops" providerGitHub = "github" + providerGitLab = "gitlab" + protocolHTTP = "http" + protocolHTTPS = "https" ) const defaultKandevTaskWorktreePathSegment = "/.kandev/tasks/" @@ -389,7 +392,7 @@ func (s *Service) createTaskRepositories(ctx context.Context, taskID, workspaceI // empty. Best-effort: lookup failures degrade to the next fallback. func (s *Service) repoDisplayLabel(ctx context.Context, repoInput TaskRepositoryInput, repositoryID string) string { if remoteURL := effectiveRemoteURL(repoInput); remoteURL != "" { - if _, owner, name, _, err := parseRemoteRepositoryURL(remoteURL); err == nil { + if _, owner, name, _, err := parseRemoteRepositoryURL(remoteURL, repoInput.Provider); err == nil { return owner + "/" + name } } @@ -497,6 +500,7 @@ func (s *Service) safeRepositoryIDForTaskWorktree(ctx context.Context, workspace SourceType: sourceTypeProvider, Provider: repo.Provider, ProviderRepoID: repo.ProviderRepoID, + ProviderHost: repo.ProviderHost, ProviderOwner: repo.ProviderOwner, ProviderName: repo.ProviderName, DefaultBranch: repo.DefaultBranch, @@ -565,6 +569,8 @@ func (s *Service) findSafeReplacementRepository(ctx context.Context, workspaceID func sameProviderIdentity(left, right *models.Repository) bool { return left.Provider == right.Provider && + normalizeProviderHost(left.Provider, left.ProviderHost) == + normalizeProviderHost(right.Provider, right.ProviderHost) && left.ProviderOwner == right.ProviderOwner && left.ProviderName == right.ProviderName } @@ -683,7 +689,9 @@ func (s *Service) resolveRepoInputLocal( func (s *Service) resolveRepoInputRemote( ctx context.Context, workspaceID string, repoInput TaskRepositoryInput, baseBranch string, ) (string, string, bool, error) { - provider, owner, name, canonicalURL, parseErr := parseRemoteRepositoryURL(effectiveRemoteURL(repoInput)) + provider, owner, name, canonicalURL, parseErr := parseRemoteRepositoryURL( + effectiveRemoteURL(repoInput), repoInput.Provider, + ) if parseErr != nil { return "", "", false, parseErr } @@ -698,10 +706,12 @@ func (s *Service) resolveRepoInputRemote( if defaultBranch == "" && repoInput.ResolveProviderDefaults && s.providerProber != nil && provider == providerGitHub { defaultBranch = s.probeProviderDefaultBranchIfMissing(ctx, workspaceID, provider, owner, name) } + providerHost := remoteProviderHost(provider, canonicalURL) repo, repoCreated, createErr := s.FindOrCreateRepository(ctx, &FindOrCreateRepositoryRequest{ WorkspaceID: workspaceID, Provider: provider, ProviderRepoID: repoInput.ProviderRepoID, + ProviderHost: providerHost, ProviderOwner: owner, ProviderName: name, RemoteURL: canonicalURL, @@ -747,23 +757,28 @@ func effectiveRemoteURL(input TaskRepositoryInput) string { return strings.TrimSpace(input.GitHubURL) } -func parseRemoteRepositoryURL(raw string) (provider, owner, name, canonical string, err error) { +func parseRemoteRepositoryURL(raw, providerHint string) (provider, owner, name, canonical string, err error) { parsed, sshStyle, parseErr := normalizeRemoteRepositoryURL(raw) if parseErr != nil { return "", "", "", "", parseErr } host := strings.ToLower(parsed.Hostname()) + originHost := strings.ToLower(parsed.Host) parts := strings.Split(strings.Trim(strings.TrimSuffix(parsed.Path, ".git"), "/"), "/") switch host { case "github.com", "www.github.com": return parseGitHubRemote(host, parts, sshStyle) case "gitlab.com", "www.gitlab.com": - return parseGitLabRemote(host, parts, sshStyle) + return parseGitLabRemote(parsed.Scheme, strings.TrimPrefix(originHost, "www."), parts, sshStyle) case "dev.azure.com": return parseAzureHTTPSRemote(parts) case "ssh.dev.azure.com": return parseAzureSSHRemote(parts) default: + if strings.EqualFold(providerHint, providerGitLab) && + (parsed.Scheme == protocolHTTP || parsed.Scheme == protocolHTTPS) { + return parseGitLabRemote(parsed.Scheme, originHost, parts, false) + } return "", "", "", "", fmt.Errorf("unsupported remote repository host: %s", host) } } @@ -800,7 +815,7 @@ func parseGitHubRemote(host string, parts []string, sshStyle bool) (string, stri return providerGitHub, owner, name, canonical, nil } -func parseGitLabRemote(host string, parts []string, sshStyle bool) (string, string, string, string, error) { +func parseGitLabRemote(scheme, host string, parts []string, sshStyle bool) (string, string, string, string, error) { for index, part := range parts { if part == "-" { parts = parts[:index] @@ -814,11 +829,31 @@ func parseGitLabRemote(host string, parts []string, sshStyle bool) (string, stri if owner == "" || name == "" { return "", "", "", "", fmt.Errorf("remote repository owner and name are required") } - canonical := fmt.Sprintf("https://gitlab.com/%s/%s.git", owner, name) + if scheme != protocolHTTP && scheme != protocolHTTPS { + scheme = protocolHTTPS + } + canonical := fmt.Sprintf("%s://%s/%s/%s.git", scheme, host, owner, name) if sshStyle { canonical = fmt.Sprintf("git@%s:%s/%s.git", host, owner, name) } - return "gitlab", owner, name, canonical, nil + return providerGitLab, owner, name, canonical, nil +} + +func remoteProviderHost(provider, remoteURL string) string { + if provider == providerGitHub { + return githubProviderHost + } + if !strings.EqualFold(provider, providerGitLab) { + return "" + } + origin, _ := ParseGitRemoteIdentity(remoteURL) + if strings.HasPrefix(origin, protocolHTTP+"://") || strings.HasPrefix(origin, protocolHTTPS+"://") { + return origin + } + if sshHost := strings.TrimPrefix(origin, "ssh://"); sshHost != origin && sshHost != "" { + return protocolHTTPS + "://" + sshHost + } + return "" } func parseAzureHTTPSRemote(parts []string) (string, string, string, string, error) { @@ -850,7 +885,7 @@ func parseAzureSSHRemote(parts []string) (string, string, string, string, error) func (s *Service) probeProviderDefaultBranchIfMissing( ctx context.Context, workspaceID, provider, owner, name string, ) string { - existing, lookupErr := s.repoEntities.GetRepositoryByProviderInfo(ctx, workspaceID, provider, owner, name) + existing, lookupErr := s.repoEntities.GetRepositoryByProviderInfo(ctx, workspaceID, provider, githubProviderHost, owner, name) if lookupErr != nil { s.logger.Warn("resolveRepoInput: failed to look up existing repo before probe", zap.String("provider", provider), @@ -873,7 +908,7 @@ func (s *Service) probeProviderDefaultBranchIfMissing( // Supports: https://github.com/owner/repo, github.com/owner/repo, // https://github.com/owner/repo.git, with optional trailing slashes. func parseGitHubRepoURL(rawURL string) (owner, name string, err error) { - provider, owner, name, _, parseErr := parseRemoteRepositoryURL(rawURL) + provider, owner, name, _, parseErr := parseRemoteRepositoryURL(rawURL, "") if parseErr != nil { return "", "", parseErr } diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index b3db787622..a8f474d313 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -689,6 +689,7 @@ func TestService_CreateTask_RewritesTaskWorktreeRepositoryIDToProviderRepository SourceType: sourceTypeLocal, LocalPath: "/root/.kandev/tasks/pr-1541-fix-skip-cle_3bm/kdlbs-kandev", Provider: "github", + ProviderHost: githubProviderHost, ProviderOwner: "kdlbs", ProviderName: "kandev", DefaultBranch: "main", @@ -749,6 +750,7 @@ func TestService_CreateTask_RewritesTaskWorktreeRepositoryIDToSafeLocalRepositor SourceType: sourceTypeLocal, LocalPath: "/root/.kandev/tasks/pr-1541-fix-skip-cle_3bm/kdlbs-kandev", Provider: "github", + ProviderHost: githubProviderHost, ProviderOwner: "kdlbs", ProviderName: "kandev", DefaultBranch: "main", @@ -922,6 +924,7 @@ func TestService_CreateTask_CreatesProviderRepositoryForTaskWorktreeRepositoryID SourceType: sourceTypeLocal, LocalPath: "/root/.kandev/tasks/pr-1541-fix-skip-cle_3bm/kdlbs-kandev", Provider: "github", + ProviderHost: githubProviderHost, ProviderOwner: "kdlbs", ProviderName: "kandev", DefaultBranch: "main", @@ -1026,6 +1029,7 @@ func TestService_CreateTask_GitHubURLIgnoresTaskWorktreeProviderMatch(t *testing SourceType: sourceTypeLocal, LocalPath: "/root/.kandev/tasks/pr-1541-fix-skip-cle_3bm/kdlbs-kandev", Provider: "github", + ProviderHost: githubProviderHost, ProviderOwner: "kdlbs", ProviderName: "kandev", DefaultBranch: "main", @@ -1076,6 +1080,7 @@ func TestService_FindOrCreateRepository_ReturnsCreatedForTaskWorktreeReplacement SourceType: sourceTypeLocal, LocalPath: "/root/.kandev/tasks/pr-1541-fix-skip-cle_3bm/kdlbs-kandev", Provider: "github", + ProviderHost: githubProviderHost, ProviderOwner: "kdlbs", ProviderName: "kandev", DefaultBranch: "main", @@ -1116,6 +1121,7 @@ func TestService_FindOrCreateRepository_ErrorsWhenTaskWorktreeReplacementDisappe SourceType: sourceTypeLocal, LocalPath: "/root/.kandev/tasks/pr-1541-fix-skip-cle_3bm/kdlbs-kandev", Provider: "github", + ProviderHost: githubProviderHost, ProviderOwner: "kdlbs", ProviderName: "kandev", DefaultBranch: "main", @@ -1140,6 +1146,25 @@ func TestService_FindOrCreateRepository_ErrorsWhenTaskWorktreeReplacementDisappe } } +func TestSameProviderIdentityIncludesNormalizedHost(t *testing.T) { + public := &models.Repository{ + Provider: "gitlab", ProviderHost: "https://gitlab.com", ProviderOwner: "group", ProviderName: "project", + } + selfManaged := &models.Repository{ + Provider: "gitlab", ProviderHost: "https://gitlab.internal", ProviderOwner: "group", ProviderName: "project", + } + if sameProviderIdentity(public, selfManaged) { + t.Fatal("repositories on different GitLab hosts matched") + } + legacyGitHub := &models.Repository{Provider: "github", ProviderOwner: "group", ProviderName: "project"} + canonicalGitHub := &models.Repository{ + Provider: "github", ProviderHost: githubProviderHost, ProviderOwner: "group", ProviderName: "project", + } + if !sameProviderIdentity(legacyGitHub, canonicalGitHub) { + t.Fatal("legacy hostless GitHub repository did not match the canonical GitHub host") + } +} + type missingReplacementLookupRepository struct { repository.RepositoryEntityRepository preserveID string diff --git a/apps/web/app/actions/workspaces.ts b/apps/web/app/actions/workspaces.ts index 7ea0953eb4..c4e3942f76 100644 --- a/apps/web/app/actions/workspaces.ts +++ b/apps/web/app/actions/workspaces.ts @@ -219,6 +219,7 @@ export async function createRepositoryAction(payload: { local_path: string; provider: string; provider_repo_id: string; + provider_host?: string; provider_owner: string; provider_name: string; default_branch: string; @@ -240,6 +241,7 @@ export async function createRepositoryAction(payload: { local_path: payload.local_path, provider: payload.provider, provider_repo_id: payload.provider_repo_id, + provider_host: payload.provider_host, provider_owner: payload.provider_owner, provider_name: payload.provider_name, default_branch: payload.default_branch, diff --git a/apps/web/app/gitlab/gitlab-page-client.tsx b/apps/web/app/gitlab/gitlab-page-client.tsx index 2e02362884..d49f8b9ad7 100644 --- a/apps/web/app/gitlab/gitlab-page-client.tsx +++ b/apps/web/app/gitlab/gitlab-page-client.tsx @@ -27,6 +27,24 @@ import { useCommittedQuery } from "@/components/gitlab/my-gitlab/use-committed-q import { ListToolbar } from "@/components/gitlab/my-gitlab/list-toolbar"; import { ResultsPagination } from "@/components/gitlab/my-gitlab/results-pagination"; import { SavePresetDialog } from "@/components/gitlab/my-gitlab/save-preset-dialog"; +import { useMRKeyToTasks } from "@/hooks/domains/gitlab/use-mr-key-to-tasks"; +import { useGitLabActionPresets } from "@/hooks/domains/gitlab/use-gitlab-action-presets"; +import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots"; +import type { Repository, Workflow, WorkflowStep } from "@/lib/types/http"; +import { + QuickTaskLauncher, + type GitLabLaunchPayload, + type GitLabTaskPreset, +} from "@/components/gitlab/my-gitlab/quick-task-launcher"; +import { toGitLabTaskPreset } from "@/components/gitlab/my-gitlab/task-presets"; +import { useAppStore } from "@/components/state-provider"; + +type GitLabPageClientProps = { + workspaceId?: string; + workflows?: Workflow[]; + steps?: WorkflowStep[]; + repositories?: Repository[]; +}; function PageHeader({ host, @@ -46,7 +64,7 @@ function PageHeader({ variant="outline" size="icon-lg" onClick={onOpenMobileSidebar} - className="md:hidden cursor-pointer" + className="h-11 w-11 md:hidden cursor-pointer" data-testid="gitlab-mobile-menu-button" aria-label="Open GitLab filters" > @@ -58,11 +76,13 @@ function PageHeader({ ); } -function NotConnectedNotice() { +function NotConnectedNotice({ reconnect }: { reconnect?: boolean }) { return ( - GitLab is not connected. Configure GitLab authentication in{" "} + {reconnect + ? "GitLab credentials are configured, but authentication failed. Reconnect in " + : "GitLab is not connected. Configure GitLab authentication in "} Settings → GitLab {" "} @@ -88,16 +108,47 @@ function ResultsList({ items, loading, error, + mrPresets, + issuePresets, + onStartTask, + mrKeyToTasks, + workspaceId, + host, }: { selection: SidebarSelection; items: Array; loading: boolean; error: string | null; + mrPresets: GitLabTaskPreset[]; + issuePresets: GitLabTaskPreset[]; + onStartTask: (payload: GitLabLaunchPayload) => void; + mrKeyToTasks: ReturnType; + workspaceId?: string; + host: string; }) { if (selection.kind === "mr") { - return ; + return ( + + ); } - return ; + return ( + + ); } type GitLabPageState = ReturnType; @@ -121,7 +172,7 @@ function useProjectOptions( }, [knownProjects, projectFilter]); } -function useGitLabPageState(searchEnabled: boolean) { +function useGitLabPageState(searchEnabled: boolean, workspaceId?: string) { const [selection, setSelection] = useState(() => ({ kind: "mr", source: "preset", @@ -144,12 +195,13 @@ function useGitLabPageState(searchEnabled: boolean) { const presets = selection.kind === "mr" ? MR_PRESETS : ISSUE_PRESETS; const search = useGitLabSearch({ + workspaceId: workspaceId ?? "", kind: selection.kind, presets, preset: selection.source === "preset" ? selection.id : "", customQuery: committedQuery, projectFilter, - enabled: searchEnabled, + enabled: searchEnabled && Boolean(workspaceId), }); const projectOptions = useProjectOptions( selection, @@ -224,8 +276,24 @@ function useGitLabPageState(searchEnabled: boolean) { }; } -function AuthenticatedLayout({ state }: { state: GitLabPageState }) { +function AuthenticatedLayout({ + workspaceId, + state, + mrPresets, + issuePresets, + onStartTask, + host, +}: { + workspaceId?: string; + state: GitLabPageState; + mrPresets: GitLabTaskPreset[]; + issuePresets: GitLabTaskPreset[]; + onStartTask: (payload: GitLabLaunchPayload) => void; + host: string; +}) { const { selection, search, projectOptions, title } = state; + const mrKeyToTasks = useMRKeyToTasks(workspaceId ?? null); + useAllWorkflowSnapshots(workspaceId ?? null); // When the user narrows by project, the toolbar badge shows how many of the // current page actually match (so it never reads "47" while three rows // render). Pagination still uses search.total so the user can navigate to @@ -262,6 +330,12 @@ function AuthenticatedLayout({ state }: { state: GitLabPageState }) { items={search.items} loading={search.loading} error={search.error} + mrPresets={mrPresets} + issuePresets={issuePresets} + onStartTask={onStartTask} + mrKeyToTasks={mrKeyToTasks} + workspaceId={workspaceId} + host={host} /> (null); +function useGitLabStatusFetch(workspaceId: string | undefined, enabled: boolean) { + const [result, setResult] = useState<{ workspaceId?: string; status: GitLabStatus | null }>({ + workspaceId, + status: null, + }); const [loading, setLoading] = useState(true); useEffect(() => { + if (!enabled || !workspaceId) { + setLoading(true); + return; + } let cancelled = false; - fetchGitLabStatus({ cache: "no-store" }) + setLoading(true); + fetchGitLabStatus({ cache: "no-store", workspaceId }) .then((s) => { - if (!cancelled) setStatus(s); + if (!cancelled) setResult({ workspaceId, status: s }); }) .catch(() => { - if (!cancelled) setStatus(null); + if (!cancelled) setResult({ workspaceId, status: null }); }) .finally(() => { if (!cancelled) setLoading(false); @@ -293,49 +375,88 @@ function useGitLabStatusFetch() { return () => { cancelled = true; }; - }, []); + }, [enabled, workspaceId]); - return { status, loading }; + const current = enabled && result.workspaceId === workspaceId; + return { status: current ? result.status : null, loading: !current || loading }; } -export function GitLabPageClient(_props: { workspaceId?: string } = {}) { - const { status, loading: statusLoading } = useGitLabStatusFetch(); - const connected = !!(status?.authenticated || status?.token_configured); - const host = status?.host ?? "https://gitlab.com"; - const state = useGitLabPageState(!statusLoading && connected); - const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); - - useEffect(() => resetKnownProjectsStore, []); - - const onOpenMobileSidebar = useCallback(() => setMobileSidebarOpen(true), []); - const { onSelect, onOpenSaveDialog } = state; - const onMobileSidebarSelect = useCallback( - (s: Parameters[0]) => { - onSelect(s); - setMobileSidebarOpen(false); - }, - [onSelect], - ); - const onMobileSaveCurrent = useCallback(() => { - setMobileSidebarOpen(false); - onOpenSaveDialog(); - }, [onOpenSaveDialog]); - +function GitLabPageBody({ + statusLoading, + connected, + reconnect, + workspaceId, + state, + mrPresets, + issuePresets, + onStartTask, + host, +}: { + statusLoading: boolean; + connected: boolean; + reconnect: boolean; + workspaceId?: string; + state: GitLabPageState; + mrPresets: GitLabTaskPreset[]; + issuePresets: GitLabTaskPreset[]; + onStartTask: (payload: GitLabLaunchPayload) => void; + host: string; +}) { + if (statusLoading) { + return
Checking GitLab status…
; + } + if (!connected) { + return ( +
+ +
+ ); + } return ( -
- - {statusLoading && ( -
Checking GitLab status…
- )} - {!statusLoading && !connected && ( -
- -
- )} - {!statusLoading && connected && } + + ); +} + +function GitLabPageOverlays({ + state, + mobileSidebarOpen, + setMobileSidebarOpen, + workspaceId, + workflows, + steps, + repositories, + configuredHost, + launchPayload, + setLaunchPayload, +}: { + state: GitLabPageState; + mobileSidebarOpen: boolean; + setMobileSidebarOpen: (open: boolean) => void; + workspaceId?: string; + workflows: Workflow[]; + steps: WorkflowStep[]; + repositories: Repository[]; + configuredHost: string; + launchPayload: GitLabLaunchPayload | null; + setLaunchPayload: (payload: GitLabLaunchPayload | null) => void; +}) { + const onMobileSidebarSelect = (selection: Parameters[0]) => { + state.onSelect(selection); + setMobileSidebarOpen(false); + }; + const onMobileSaveCurrent = () => { + setMobileSidebarOpen(false); + state.onOpenSaveDialog(); + }; + return ( + <> + setLaunchPayload(null)} + /> + + ); +} + +function useGitLabWorkspaceScope(serverWorkspaceId?: string) { + const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId); + const workspaceId = activeWorkspaceId ?? serverWorkspaceId; + const switching = Boolean(serverWorkspaceId && workspaceId && serverWorkspaceId !== workspaceId); + const { status, loading } = useGitLabStatusFetch(workspaceId, !switching); + useEffect(() => { + if (switching) window.location.reload(); + }, [switching]); + return { + workspaceId, + switching, + status, + statusLoading: switching || loading, + connected: Boolean(status?.authenticated), + reconnect: Boolean(status?.token_configured && !status.authenticated), + }; +} + +function useGitLabTaskPresets(workspaceId: string | undefined, switching: boolean) { + const { presets } = useGitLabActionPresets(switching ? null : workspaceId); + const mrPresets = useMemo(() => (presets?.mr ?? []).map(toGitLabTaskPreset), [presets?.mr]); + const issuePresets = useMemo( + () => (presets?.issue ?? []).map(toGitLabTaskPreset), + [presets?.issue], + ); + return { mrPresets, issuePresets }; +} + +export function GitLabPageClient({ + workspaceId, + workflows = [], + steps = [], + repositories = [], +}: GitLabPageClientProps = {}) { + const scope = useGitLabWorkspaceScope(workspaceId); + const { status, statusLoading, connected, reconnect } = scope; + const host = status?.host ?? "https://gitlab.com"; + const state = useGitLabPageState(!statusLoading && connected, scope.workspaceId); + const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); + const [launchPayload, setLaunchPayload] = useState(null); + const { mrPresets, issuePresets } = useGitLabTaskPresets(scope.workspaceId, scope.switching); + + useEffect(() => resetKnownProjectsStore, []); + + const onOpenMobileSidebar = useCallback(() => setMobileSidebarOpen(true), []); + + return ( +
+ + +
); } diff --git a/apps/web/app/gitlab/page.tsx b/apps/web/app/gitlab/page.tsx index 5bbe390865..4a41475d26 100644 --- a/apps/web/app/gitlab/page.tsx +++ b/apps/web/app/gitlab/page.tsx @@ -1,17 +1,27 @@ -import { listWorkspacesAction } from "@/app/actions/workspaces"; +import { + listWorkspacesAction, + listWorkflowsAction, + listRepositoriesAction, + listWorkspaceWorkflowStepsAction, +} from "@/app/actions/workspaces"; import { fetchUserSettings } from "@/lib/api"; import { StateHydrator } from "@/components/state-hydrator"; import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; import { GitLabPageClient } from "./gitlab-page-client"; -import type { Workspace, UserSettingsResponse } from "@/lib/types/http"; +import type { + Repository, + Workflow, + WorkflowStep, + Workspace, + UserSettingsResponse, +} from "@/lib/types/http"; import type { AppState } from "@/lib/state/store"; -// Minimal SSR entrypoint for /gitlab. v1 surface: list the current user's -// open MRs + issues and let them click through to GitLab. The browse-and- -// trigger flow (review/issue watchers, presets) is parallel to /github and -// will land alongside the watchers backend in a follow-up. export default async function GitLabPage() { let workspaces: Workspace[] = []; + let workflows: Workflow[] = []; + let steps: WorkflowStep[] = []; + let repositories: Repository[] = []; let workspaceId: string | undefined; let userSettingsResponse: UserSettingsResponse | null = null; @@ -23,6 +33,16 @@ export default async function GitLabPage() { workspaces = workspacesResponse.workspaces; userSettingsResponse = settingsResponse; workspaceId = settingsResponse?.settings?.workspace_id || workspaces[0]?.id; + if (workspaceId) { + const [workflowsResponse, repositoriesResponse, stepsResponse] = await Promise.all([ + listWorkflowsAction(workspaceId), + listRepositoriesAction(workspaceId), + listWorkspaceWorkflowStepsAction(workspaceId), + ]); + workflows = workflowsResponse.workflows; + repositories = repositoriesResponse.repositories; + steps = stepsResponse.steps; + } } catch (error) { console.error("Failed to load GitLab page data:", error); } @@ -31,13 +51,29 @@ export default async function GitLabPage() { const initialState: Partial = { workspaces: { items: workspaces, activeId: workspaceId ?? null }, + workflows: { + items: workflows.map((workflow) => ({ + id: workflow.id, + workspaceId: workflow.workspace_id, + name: workflow.name, + description: workflow.description ?? null, + sortOrder: workflow.sort_order ?? 0, + ...(workflow.agent_profile_id ? { agent_profile_id: workflow.agent_profile_id } : {}), + })), + activeId: workflows[0]?.id ?? null, + }, userSettings: { ...mappedUserSettings, workspaceId: workspaceId ?? null }, }; return ( <> - + ); } diff --git a/apps/web/app/settings/workspace/workspace-repositories-client.tsx b/apps/web/app/settings/workspace/workspace-repositories-client.tsx index 26caf92a93..bfdbad8520 100644 --- a/apps/web/app/settings/workspace/workspace-repositories-client.tsx +++ b/apps/web/app/settings/workspace/workspace-repositories-client.tsx @@ -67,6 +67,7 @@ function buildDraftRepo( local_path: path, provider: "", provider_repo_id: "", + provider_host: "", provider_owner: "", provider_name: "", default_branch: selectedRepo?.default_branch ?? "", @@ -107,6 +108,7 @@ async function saveNewRepository( local_path: repo.local_path, provider: repo.provider, provider_repo_id: repo.provider_repo_id, + provider_host: repo.provider_host, provider_owner: repo.provider_owner, provider_name: repo.provider_name, default_branch: repo.default_branch, @@ -160,6 +162,7 @@ async function saveExistingRepository({ local_path: repo.local_path, provider: repo.provider, provider_repo_id: repo.provider_repo_id, + provider_host: repo.provider_host, provider_owner: repo.provider_owner, provider_name: repo.provider_name, default_branch: repo.default_branch, diff --git a/apps/web/app/settings/workspace/workspace-repositories-dirty.ts b/apps/web/app/settings/workspace/workspace-repositories-dirty.ts index 47e6106b8d..71f592fb20 100644 --- a/apps/web/app/settings/workspace/workspace-repositories-dirty.ts +++ b/apps/web/app/settings/workspace/workspace-repositories-dirty.ts @@ -9,6 +9,7 @@ const repositoryFields: Array = [ "local_path", "provider", "provider_repo_id", + "provider_host", "provider_owner", "provider_name", "default_branch", diff --git a/apps/web/components/gitlab/action-presets-section.tsx b/apps/web/components/gitlab/action-presets-section.tsx new file mode 100644 index 0000000000..99530561dc --- /dev/null +++ b/apps/web/components/gitlab/action-presets-section.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { IconPlus, IconRefresh, IconTrash } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { CardContent } from "@kandev/ui/card"; +import { Input } from "@kandev/ui/input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@kandev/ui/tabs"; +import { Textarea } from "@kandev/ui/textarea"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsSection } from "@/components/settings/settings-section"; +import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider"; +import { useToast } from "@/components/toast-provider"; +import { useGitLabActionPresets } from "@/hooks/domains/gitlab/use-gitlab-action-presets"; +import type { GitLabActionPreset, GitLabActionPresets } from "@/lib/types/gitlab"; + +function newPreset(): GitLabActionPreset { + return { + id: `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`, + label: "New action", + hint: "", + icon: "sparkle", + prompt_template: "", + }; +} + +function PresetList({ + presets, + onChange, + addLabel, +}: { + presets: GitLabActionPreset[]; + onChange: (next: GitLabActionPreset[]) => void; + addLabel: string; +}) { + const patch = (index: number, change: Partial) => + onChange( + presets.map((preset, current) => (current === index ? { ...preset, ...change } : preset)), + ); + return ( +
+ {presets.map((preset, index) => ( +
+
+ patch(index, { label: event.target.value })} + /> + patch(index, { hint: event.target.value })} + /> + +
+