diff --git a/components/backend/handlers/project_settings.go b/components/backend/handlers/project_settings.go new file mode 100644 index 000000000..1908cf487 --- /dev/null +++ b/components/backend/handlers/project_settings.go @@ -0,0 +1,126 @@ +package handlers + +import ( + "log" + "net/http" + + "github.com/gin-gonic/gin" + "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +var projectSettingsGVR = schema.GroupVersionResource{ + Group: "vteam.ambient-code", + Version: "v1alpha1", + Resource: "projectsettings", +} + +// GetProjectSettings handles GET /api/projects/:projectName/project-settings +func GetProjectSettings(c *gin.Context) { + projectName := c.Param("projectName") + _, reqDyn := GetK8sClientsForRequest(c) + if reqDyn == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + c.Abort() + return + } + + obj, err := reqDyn.Resource(projectSettingsGVR).Namespace(projectName).Get(c.Request.Context(), "projectsettings", v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusOK, gin.H{}) + return + } + log.Printf("Failed to get ProjectSettings in %s: %v", projectName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read project settings"}) + return + } + + result := gin.H{} + if configRepo, found, _ := unstructured.NestedMap(obj.Object, "spec", "defaultConfigRepo"); found { + result["defaultConfigRepo"] = configRepo + } + + c.JSON(http.StatusOK, result) +} + +// UpdateProjectSettings handles PUT /api/projects/:projectName/project-settings +func UpdateProjectSettings(c *gin.Context) { + projectName := c.Param("projectName") + _, reqDyn := GetK8sClientsForRequest(c) + if reqDyn == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + c.Abort() + return + } + + var req struct { + DefaultConfigRepo *struct { + GitURL string `json:"gitUrl"` + Branch string `json:"branch,omitempty"` + } `json:"defaultConfigRepo"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Get current ProjectSettings CR + obj, err := reqDyn.Resource(projectSettingsGVR).Namespace(projectName).Get(c.Request.Context(), "projectsettings", v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "Project settings not found"}) + return + } + log.Printf("Failed to get ProjectSettings in %s: %v", projectName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read project settings"}) + return + } + + // Merge defaultConfigRepo into spec + spec, _, _ := unstructured.NestedMap(obj.Object, "spec") + if spec == nil { + spec = map[string]interface{}{} + } + + if req.DefaultConfigRepo != nil && req.DefaultConfigRepo.GitURL != "" { + configRepo := map[string]interface{}{ + "gitUrl": req.DefaultConfigRepo.GitURL, + } + if req.DefaultConfigRepo.Branch != "" { + configRepo["branch"] = req.DefaultConfigRepo.Branch + } + spec["defaultConfigRepo"] = configRepo + } else { + delete(spec, "defaultConfigRepo") + } + + if err := unstructured.SetNestedMap(obj.Object, spec, "spec"); err != nil { + log.Printf("Failed to set spec on ProjectSettings in %s: %v", projectName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update project settings"}) + return + } + + _, err = reqDyn.Resource(projectSettingsGVR).Namespace(projectName).Update(c.Request.Context(), obj, v1.UpdateOptions{}) + if err != nil { + log.Printf("Failed to update ProjectSettings in %s: %v", projectName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update project settings"}) + return + } + + // Return updated settings + result := gin.H{} + if req.DefaultConfigRepo != nil && req.DefaultConfigRepo.GitURL != "" { + configRepo := map[string]interface{}{ + "gitUrl": req.DefaultConfigRepo.GitURL, + } + if req.DefaultConfigRepo.Branch != "" { + configRepo["branch"] = req.DefaultConfigRepo.Branch + } + result["defaultConfigRepo"] = configRepo + } + + c.JSON(http.StatusOK, result) +} diff --git a/components/backend/handlers/project_settings_test.go b/components/backend/handlers/project_settings_test.go new file mode 100644 index 000000000..0cca486a7 --- /dev/null +++ b/components/backend/handlers/project_settings_test.go @@ -0,0 +1,330 @@ +//go:build test + +package handlers + +import ( + "context" + "net/http" + + test_constants "ambient-code-backend/tests/constants" + "ambient-code-backend/tests/logger" + "ambient-code-backend/tests/test_utils" + + "github.com/gin-gonic/gin" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +var _ = Describe("ProjectSettings Handler", Label(test_constants.LabelUnit, test_constants.LabelHandlers, test_constants.LabelProjectSettings), func() { + var ( + httpUtils *test_utils.HTTPTestUtils + k8sUtils *test_utils.K8sTestUtils + testToken string + ) + + BeforeEach(func() { + logger.Log("Setting up ProjectSettings Handler test") + + k8sUtils = test_utils.NewK8sTestUtils(false, "test-project") + SetupHandlerDependencies(k8sUtils) + + httpUtils = test_utils.NewHTTPTestUtils() + + ctx := context.Background() + _, err := k8sUtils.K8sClient.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "test-project"}, + }, metav1.CreateOptions{}) + if err != nil && !errors.IsAlreadyExists(err) { + Expect(err).NotTo(HaveOccurred()) + } + _, err = k8sUtils.CreateTestRole(ctx, "test-project", "test-full-access-role", []string{"get", "list", "create", "update", "delete", "patch"}, "*", "") + Expect(err).NotTo(HaveOccurred()) + + token, _, err := httpUtils.SetValidTestToken( + k8sUtils, + "test-project", + []string{"get", "list", "create", "update", "delete", "patch"}, + "*", + "", + "test-full-access-role", + ) + Expect(err).NotTo(HaveOccurred()) + testToken = token + }) + + AfterEach(func() { + if k8sUtils != nil { + _ = k8sUtils.K8sClient.CoreV1().Namespaces().Delete(context.Background(), "test-project", metav1.DeleteOptions{}) + } + }) + + // Helper to create a ProjectSettings CR in the fake cluster + createProjectSettings := func(spec map[string]interface{}) { + obj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "vteam.ambient-code/v1alpha1", + "kind": "ProjectSettings", + "metadata": map[string]interface{}{ + "name": "projectsettings", + "namespace": "test-project", + }, + "spec": spec, + }, + } + k8sUtils.CreateCustomResource(context.Background(), projectSettingsGVR, "test-project", obj) + } + + Context("GetProjectSettings", func() { + It("Should return empty object when CR does not exist", func() { + ginCtx := httpUtils.CreateTestGinContext("GET", "/api/projects/test-project/project-settings", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + httpUtils.SetAuthHeader(testToken) + + GetProjectSettings(ginCtx) + + httpUtils.AssertHTTPStatus(http.StatusOK) + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response).NotTo(HaveKey("defaultConfigRepo"), "Should not have defaultConfigRepo when CR does not exist") + + logger.Log("Correctly returned empty when ProjectSettings CR missing") + }) + + It("Should return defaultConfigRepo when set", func() { + createProjectSettings(map[string]interface{}{ + "groupAccess": []interface{}{}, + "defaultConfigRepo": map[string]interface{}{ + "gitUrl": "https://github.com/org/session-config.git", + "branch": "develop", + }, + }) + + ginCtx := httpUtils.CreateTestGinContext("GET", "/api/projects/test-project/project-settings", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + httpUtils.SetAuthHeader(testToken) + + GetProjectSettings(ginCtx) + + httpUtils.AssertHTTPStatus(http.StatusOK) + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response).To(HaveKey("defaultConfigRepo")) + + configRepo := response["defaultConfigRepo"].(map[string]interface{}) + Expect(configRepo["gitUrl"]).To(Equal("https://github.com/org/session-config.git")) + Expect(configRepo["branch"]).To(Equal("develop")) + + logger.Log("Successfully returned defaultConfigRepo") + }) + + It("Should return empty object when CR exists but no config repo set", func() { + createProjectSettings(map[string]interface{}{ + "groupAccess": []interface{}{}, + }) + + ginCtx := httpUtils.CreateTestGinContext("GET", "/api/projects/test-project/project-settings", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + httpUtils.SetAuthHeader(testToken) + + GetProjectSettings(ginCtx) + + httpUtils.AssertHTTPStatus(http.StatusOK) + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response).NotTo(HaveKey("defaultConfigRepo")) + + logger.Log("Correctly returned empty when no config repo set") + }) + + It("Should require authentication", func() { + restore := WithAuthCheckEnabled() + defer restore() + + ginCtx := httpUtils.CreateTestGinContext("GET", "/api/projects/test-project/project-settings", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + + GetProjectSettings(ginCtx) + + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + httpUtils.AssertErrorMessage("Invalid or missing token") + }) + }) + + Context("UpdateProjectSettings", func() { + It("Should set defaultConfigRepo on existing CR", func() { + createProjectSettings(map[string]interface{}{ + "groupAccess": []interface{}{}, + }) + + requestBody := map[string]interface{}{ + "defaultConfigRepo": map[string]interface{}{ + "gitUrl": "https://github.com/org/my-config.git", + "branch": "main", + }, + } + + ginCtx := httpUtils.CreateTestGinContext("PUT", "/api/projects/test-project/project-settings", requestBody) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + httpUtils.SetAuthHeader(testToken) + + UpdateProjectSettings(ginCtx) + + httpUtils.AssertHTTPStatus(http.StatusOK) + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response).To(HaveKey("defaultConfigRepo")) + + configRepo := response["defaultConfigRepo"].(map[string]interface{}) + Expect(configRepo["gitUrl"]).To(Equal("https://github.com/org/my-config.git")) + Expect(configRepo["branch"]).To(Equal("main")) + + logger.Log("Successfully set defaultConfigRepo") + }) + + It("Should clear defaultConfigRepo when gitUrl is empty", func() { + createProjectSettings(map[string]interface{}{ + "groupAccess": []interface{}{}, + "defaultConfigRepo": map[string]interface{}{ + "gitUrl": "https://github.com/org/old-config.git", + }, + }) + + requestBody := map[string]interface{}{ + "defaultConfigRepo": map[string]interface{}{ + "gitUrl": "", + }, + } + + ginCtx := httpUtils.CreateTestGinContext("PUT", "/api/projects/test-project/project-settings", requestBody) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + httpUtils.SetAuthHeader(testToken) + + UpdateProjectSettings(ginCtx) + + httpUtils.AssertHTTPStatus(http.StatusOK) + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response).NotTo(HaveKey("defaultConfigRepo")) + + logger.Log("Successfully cleared defaultConfigRepo") + }) + + It("Should preserve existing spec fields like groupAccess", func() { + createProjectSettings(map[string]interface{}{ + "groupAccess": []interface{}{ + map[string]interface{}{ + "groupName": "team-alpha", + "role": "edit", + }, + }, + "runnerSecretsName": "my-secret", + }) + + requestBody := map[string]interface{}{ + "defaultConfigRepo": map[string]interface{}{ + "gitUrl": "https://github.com/org/config.git", + }, + } + + ginCtx := httpUtils.CreateTestGinContext("PUT", "/api/projects/test-project/project-settings", requestBody) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + httpUtils.SetAuthHeader(testToken) + + UpdateProjectSettings(ginCtx) + + httpUtils.AssertHTTPStatus(http.StatusOK) + + // Verify the CR still has groupAccess and runnerSecretsName + ctx := context.Background() + obj, err := k8sUtils.DynamicClient.Resource(projectSettingsGVR).Namespace("test-project").Get(ctx, "projectsettings", metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + + groupAccess, found, _ := unstructured.NestedSlice(obj.Object, "spec", "groupAccess") + Expect(found).To(BeTrue(), "groupAccess should be preserved") + Expect(groupAccess).To(HaveLen(1)) + + secretsName, found, _ := unstructured.NestedString(obj.Object, "spec", "runnerSecretsName") + Expect(found).To(BeTrue(), "runnerSecretsName should be preserved") + Expect(secretsName).To(Equal("my-secret")) + + logger.Log("Successfully preserved existing spec fields") + }) + + It("Should return 404 when CR does not exist", func() { + requestBody := map[string]interface{}{ + "defaultConfigRepo": map[string]interface{}{ + "gitUrl": "https://github.com/org/config.git", + }, + } + + ginCtx := httpUtils.CreateTestGinContext("PUT", "/api/projects/test-project/project-settings", requestBody) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + httpUtils.SetAuthHeader(testToken) + + UpdateProjectSettings(ginCtx) + + httpUtils.AssertHTTPStatus(http.StatusNotFound) + + logger.Log("Correctly returned 404 when CR missing") + }) + + It("Should reject invalid JSON body", func() { + createProjectSettings(map[string]interface{}{ + "groupAccess": []interface{}{}, + }) + + ginCtx := httpUtils.CreateTestGinContext("PUT", "/api/projects/test-project/project-settings", "invalid-json") + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + httpUtils.SetAuthHeader(testToken) + + UpdateProjectSettings(ginCtx) + + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + + logger.Log("Correctly rejected invalid JSON") + }) + + It("Should require authentication", func() { + restore := WithAuthCheckEnabled() + defer restore() + + requestBody := map[string]interface{}{ + "defaultConfigRepo": map[string]interface{}{ + "gitUrl": "https://github.com/org/config.git", + }, + } + + ginCtx := httpUtils.CreateTestGinContext("PUT", "/api/projects/test-project/project-settings", requestBody) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + + UpdateProjectSettings(ginCtx) + + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + httpUtils.AssertErrorMessage("Invalid or missing token") + }) + }) +}) diff --git a/components/backend/handlers/repository.go b/components/backend/handlers/repository.go index 5da10a494..e390d9b33 100644 --- a/components/backend/handlers/repository.go +++ b/components/backend/handlers/repository.go @@ -124,31 +124,3 @@ func ValidateProjectRepository(ctx context.Context, repoURL string, userID strin return info, nil } - -// EnrichProjectSettingsWithProviders adds provider information to repositories in ProjectSettings -func EnrichProjectSettingsWithProviders(repositories []map[string]interface{}) []map[string]interface{} { - enriched := make([]map[string]interface{}, len(repositories)) - - for i, repo := range repositories { - enrichedRepo := make(map[string]interface{}) - - // Copy existing fields - for k, v := range repo { - enrichedRepo[k] = v - } - - // Add provider if not already present - if _, hasProvider := repo["provider"]; !hasProvider { - if url, hasURL := repo["url"].(string); hasURL { - provider := DetectRepositoryProvider(url) - if provider != "" { - enrichedRepo["provider"] = string(provider) - } - } - } - - enriched[i] = enrichedRepo - } - - return enriched -} diff --git a/components/backend/handlers/repository_test.go b/components/backend/handlers/repository_test.go index 81226ef38..7ecff5bdf 100644 --- a/components/backend/handlers/repository_test.go +++ b/components/backend/handlers/repository_test.go @@ -335,154 +335,6 @@ var _ = Describe("Repository Handler", Label(test_constants.LabelUnit, test_cons }) }) - Context("ProjectSettings Enhancement", func() { - Describe("EnrichProjectSettingsWithProviders", func() { - It("Should add provider information to repositories", func() { - // Arrange - repositories := []map[string]interface{}{ - { - "name": "GitHub Repo", - "url": "https://github.com/user/repo.git", - }, - { - "name": "GitLab Repo", - "url": "https://gitlab.com/group/project.git", - }, - } - - // Act - enriched := EnrichProjectSettingsWithProviders(repositories) - - // Assert - Expect(enriched).To(HaveLen(2)) - - // Check GitHub repository - githubRepo := enriched[0] - Expect(githubRepo["name"]).To(Equal("GitHub Repo")) - Expect(githubRepo["url"]).To(Equal("https://github.com/user/repo.git")) - Expect(githubRepo["provider"]).To(Equal("github")) - - // Check GitLab repository - gitlabRepo := enriched[1] - Expect(gitlabRepo["name"]).To(Equal("GitLab Repo")) - Expect(gitlabRepo["url"]).To(Equal("https://gitlab.com/group/project.git")) - Expect(gitlabRepo["provider"]).To(Equal("gitlab")) - - logger.Log("Successfully enriched repositories with provider information") - }) - - It("Should preserve existing provider information", func() { - // Arrange - repositories := []map[string]interface{}{ - { - "name": "Custom Repo", - "url": "https://github.com/user/repo.git", - "provider": "custom-provider", // Existing provider - }, - } - - // Act - enriched := EnrichProjectSettingsWithProviders(repositories) - - // Assert - Expect(enriched).To(HaveLen(1)) - Expect(enriched[0]["provider"]).To(Equal("custom-provider"), "Should preserve existing provider") - - logger.Log("Preserved existing provider information") - }) - - It("Should handle repositories without URL", func() { - // Arrange - repositories := []map[string]interface{}{ - { - "name": "Repo Without URL", - // No URL field - }, - } - - // Act - enriched := EnrichProjectSettingsWithProviders(repositories) - - // Assert - Expect(enriched).To(HaveLen(1)) - Expect(enriched[0]["name"]).To(Equal("Repo Without URL")) - // Provider should not be added if no URL - _, hasProvider := enriched[0]["provider"] - Expect(hasProvider).To(BeFalse(), "Should not add provider if no URL") - - logger.Log("Handled repository without URL correctly") - }) - - It("Should handle empty repository list", func() { - // Arrange - repositories := []map[string]interface{}{} - - // Act - enriched := EnrichProjectSettingsWithProviders(repositories) - - // Assert - Expect(enriched).To(HaveLen(0)) - - logger.Log("Handled empty repository list correctly") - }) - - It("Should handle repositories with unknown provider", func() { - // Arrange - repositories := []map[string]interface{}{ - { - "name": "Unknown Provider Repo", - "url": "https://bitbucket.org/user/repo.git", - }, - } - - // Act - enriched := EnrichProjectSettingsWithProviders(repositories) - - // Assert - Expect(enriched).To(HaveLen(1)) - - // Should not add empty provider for unknown providers - provider, hasProvider := enriched[0]["provider"] - if hasProvider { - Expect(provider).NotTo(BeEmpty()) - } - - logger.Log("Handled repository with unknown provider correctly") - }) - - It("Should preserve all existing fields", func() { - // Arrange - repositories := []map[string]interface{}{ - { - "name": "Full Repo", - "url": "https://github.com/user/repo.git", - "description": "A test repository", - "branch": "main", - "config": map[string]interface{}{ - "timeout": 300, - }, - }, - } - - // Act - enriched := EnrichProjectSettingsWithProviders(repositories) - - // Assert - Expect(enriched).To(HaveLen(1)) - repo := enriched[0] - - Expect(repo["name"]).To(Equal("Full Repo")) - Expect(repo["url"]).To(Equal("https://github.com/user/repo.git")) - Expect(repo["description"]).To(Equal("A test repository")) - Expect(repo["branch"]).To(Equal("main")) - Expect(repo["config"]).NotTo(BeNil()) - Expect(repo["provider"]).To(Equal("github")) - - logger.Log("Preserved all existing fields while adding provider") - }) - }) - }) - Context("Error Handling", func() { It("Should handle nil repository info gracefully", func() { // This tests the robustness of the functions @@ -501,25 +353,5 @@ var _ = Describe("Repository Handler", Label(test_constants.LabelUnit, test_cons }) } }) - - It("Should handle malformed repository data in enrichment", func() { - // Arrange - malformed repository data - repositories := []map[string]interface{}{ - { - "url": 123, // Invalid type for URL - }, - { - "url": nil, // Nil URL - }, - } - - // Act - enriched := EnrichProjectSettingsWithProviders(repositories) - - // Assert - Should not panic - Expect(enriched).To(HaveLen(2)) - - logger.Log("Handled malformed repository data without panic") - }) }) }) diff --git a/components/backend/handlers/sessions.go b/components/backend/handlers/sessions.go index f8f432751..a5557d7a7 100644 --- a/components/backend/handlers/sessions.go +++ b/components/backend/handlers/sessions.go @@ -207,6 +207,18 @@ func parseSpec(spec map[string]interface{}) types.AgenticSessionSpec { result.ActiveWorkflow = ws } + // Parse configRepo + if configRepo, ok := spec["configRepo"].(map[string]interface{}); ok { + cr := &types.ConfigRepoSelection{} + if gitURL, ok := configRepo["gitUrl"].(string); ok { + cr.GitURL = gitURL + } + if branch, ok := configRepo["branch"].(string); ok { + cr.Branch = branch + } + result.ConfigRepo = cr + } + return result } @@ -657,6 +669,16 @@ func CreateSession(c *gin.Context) { } } + // Set session-config repo on spec if provided + if req.ConfigRepo != nil && strings.TrimSpace(req.ConfigRepo.GitURL) != "" { + spec := session["spec"].(map[string]interface{}) + cr := map[string]interface{}{"gitUrl": req.ConfigRepo.GitURL} + if strings.TrimSpace(req.ConfigRepo.Branch) != "" { + cr["branch"] = req.ConfigRepo.Branch + } + spec["configRepo"] = cr + } + // Add userContext derived from authenticated caller; ignore client-supplied userId { uidVal, _ := c.Get("userID") diff --git a/components/backend/routes.go b/components/backend/routes.go index 69f52d096..f2585b1a8 100644 --- a/components/backend/routes.go +++ b/components/backend/routes.go @@ -109,6 +109,9 @@ func registerRoutes(r *gin.Engine) { projectGroup.GET("/integration-secrets", handlers.ListIntegrationSecrets) projectGroup.PUT("/integration-secrets", handlers.UpdateIntegrationSecrets) + projectGroup.GET("/project-settings", handlers.GetProjectSettings) + projectGroup.PUT("/project-settings", handlers.UpdateProjectSettings) + // GitLab authentication endpoints (DEPRECATED - moved to cluster-scoped) // Kept for backward compatibility, will be removed in future version projectGroup.POST("/auth/gitlab/connect", handlers.ConnectGitLabGlobal) diff --git a/components/backend/tests/constants/labels.go b/components/backend/tests/constants/labels.go index 2c48e0f7c..24e893c73 100644 --- a/components/backend/tests/constants/labels.go +++ b/components/backend/tests/constants/labels.go @@ -12,19 +12,20 @@ const ( LabelTypes = "types" // Specific component labels for handlers - LabelRepo = "repo" - LabelRepoSeed = "repo_seed" - LabelSecrets = "secrets" - LabelRepository = "repository" - LabelMiddleware = "middleware" - LabelPermissions = "permissions" - LabelProjects = "projects" - LabelGitHubAuth = "github-auth" - LabelGitLabAuth = "gitlab-auth" - LabelSessions = "sessions" - LabelContent = "content" - LabelDisplayName = "display-name" - LabelHealth = "health" + LabelRepo = "repo" + LabelRepoSeed = "repo_seed" + LabelSecrets = "secrets" + LabelRepository = "repository" + LabelMiddleware = "middleware" + LabelPermissions = "permissions" + LabelProjects = "projects" + LabelGitHubAuth = "github-auth" + LabelGitLabAuth = "gitlab-auth" + LabelSessions = "sessions" + LabelProjectSettings = "project-settings" + LabelContent = "content" + LabelDisplayName = "display-name" + LabelHealth = "health" // Specific component labels for other areas LabelOperations = "operations" // for git operations diff --git a/components/backend/types/session.go b/components/backend/types/session.go index bc2253b12..982a152e7 100644 --- a/components/backend/types/session.go +++ b/components/backend/types/session.go @@ -27,6 +27,8 @@ type AgenticSessionSpec struct { Repos []SimpleRepo `json:"repos,omitempty"` // Active workflow for dynamic workflow switching ActiveWorkflow *WorkflowSelection `json:"activeWorkflow,omitempty"` + // Session configuration repository (CLAUDE.md, .claude/ settings, rules, skills, agents, .mcp.json) + ConfigRepo *ConfigRepoSelection `json:"configRepo,omitempty"` } // SimpleRepo represents a simplified repository configuration @@ -56,11 +58,13 @@ type CreateAgenticSessionRequest struct { Interactive *bool `json:"interactive,omitempty"` ParentSessionID string `json:"parent_session_id,omitempty"` // Multi-repo support - Repos []SimpleRepo `json:"repos,omitempty"` - UserContext *UserContext `json:"userContext,omitempty"` - EnvironmentVariables map[string]string `json:"environmentVariables,omitempty"` - Labels map[string]string `json:"labels,omitempty"` - Annotations map[string]string `json:"annotations,omitempty"` + Repos []SimpleRepo `json:"repos,omitempty"` + // Session configuration repository + ConfigRepo *ConfigRepoSelection `json:"configRepo,omitempty"` + UserContext *UserContext `json:"userContext,omitempty"` + EnvironmentVariables map[string]string `json:"environmentVariables,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` } type CloneSessionRequest struct { @@ -107,6 +111,12 @@ type ReconciledWorkflow struct { AppliedAt *string `json:"appliedAt,omitempty"` } +// ConfigRepoSelection references a session-config repository +type ConfigRepoSelection struct { + GitURL string `json:"gitUrl" binding:"required"` + Branch string `json:"branch,omitempty"` +} + // Condition mirrors metav1.Condition for API transport type Condition struct { Type string `json:"type"` diff --git a/components/frontend/src/components/create-session-dialog.tsx b/components/frontend/src/components/create-session-dialog.tsx index f25ec3770..481f6fc7b 100644 --- a/components/frontend/src/components/create-session-dialog.tsx +++ b/components/frontend/src/components/create-session-dialog.tsx @@ -1,11 +1,11 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import * as z from "zod"; import Link from "next/link"; -import { AlertTriangle, CheckCircle2, Loader2 } from "lucide-react"; +import { AlertTriangle, CheckCircle2, GitBranch, Loader2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; @@ -35,6 +35,7 @@ import { import type { CreateAgenticSessionRequest } from "@/types/agentic-session"; import { useCreateSession } from "@/services/queries/use-sessions"; import { useIntegrationsStatus } from "@/services/queries/use-integrations"; +import { useProjectSettings } from "@/services/queries/use-project-settings"; import { errorToast } from "@/hooks/use-toast"; const models = [ @@ -51,6 +52,8 @@ const formSchema = z.object({ temperature: z.number().min(0).max(2), maxTokens: z.number().min(100).max(8000), timeout: z.number().min(60).max(1800), + configRepoUrl: z.string().url("Must be a valid URL").optional().or(z.literal("")), + configRepoBranch: z.string().max(100).optional(), }); type FormValues = z.infer; @@ -71,6 +74,7 @@ export function CreateSessionDialog({ const createSessionMutation = useCreateSession(); const { data: integrationsStatus } = useIntegrationsStatus(); + const { data: projectSettings } = useProjectSettings(projectName); const githubConfigured = integrationsStatus?.github?.active != null; const gitlabConfigured = integrationsStatus?.gitlab?.connected ?? false; @@ -85,9 +89,24 @@ export function CreateSessionDialog({ temperature: 0.7, maxTokens: 4000, timeout: 300, + configRepoUrl: "", + configRepoBranch: "", }, }); + // Pre-fill config repo from workspace default settings + useEffect(() => { + if (projectSettings?.defaultConfigRepo?.gitUrl) { + const current = form.getValues(); + if (!current.configRepoUrl) { + form.setValue("configRepoUrl", projectSettings.defaultConfigRepo.gitUrl); + } + if (!current.configRepoBranch && projectSettings.defaultConfigRepo.branch) { + form.setValue("configRepoBranch", projectSettings.defaultConfigRepo.branch); + } + } + }, [projectSettings, form]); + const onSubmit = async (values: FormValues) => { if (!projectName) return; @@ -105,6 +124,15 @@ export function CreateSessionDialog({ request.displayName = trimmedName; } + const trimmedConfigUrl = values.configRepoUrl?.trim(); + if (trimmedConfigUrl) { + request.configRepo = { gitUrl: trimmedConfigUrl }; + const trimmedBranch = values.configRepoBranch?.trim(); + if (trimmedBranch) { + request.configRepo.branch = trimmedBranch; + } + } + createSessionMutation.mutate( { projectName, data: request }, { @@ -193,6 +221,53 @@ export function CreateSessionDialog({ )} /> + {/* Config Repo (optional) */} +
+ Config Repo +

+ A Git repository with session configuration (CLAUDE.md, .claude/ rules, skills, agents, .mcp.json). + Contents are overlaid into the workspace at startup. +

+
+ ( + + +
+ + +
+
+ +
+ )} + /> + ( + + + + + + + )} + /> +
+
+ {/* Integration auth status */}
Integrations diff --git a/components/frontend/src/components/create-workspace-dialog.tsx b/components/frontend/src/components/create-workspace-dialog.tsx index c71e20aa4..04389f5be 100644 --- a/components/frontend/src/components/create-workspace-dialog.tsx +++ b/components/frontend/src/components/create-workspace-dialog.tsx @@ -15,9 +15,10 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { CreateProjectRequest } from "@/types/project"; -import { Save, Loader2, Info } from "lucide-react"; +import { Save, Loader2, Info, GitBranch } from "lucide-react"; import { successToast, errorToast } from "@/hooks/use-toast"; import { useCreateProject } from "@/services/queries"; +import { useUpdateProjectSettings } from "@/services/queries/use-project-settings"; import { useClusterInfo } from "@/hooks/use-cluster-info"; import { Alert, AlertDescription } from "@/components/ui/alert"; @@ -32,8 +33,11 @@ export function CreateWorkspaceDialog({ }: CreateWorkspaceDialogProps) { const router = useRouter(); const createProjectMutation = useCreateProject(); + const updateSettingsMutation = useUpdateProjectSettings(); const { isOpenShift, isLoading: clusterLoading } = useClusterInfo(); const [error, setError] = useState(null); + const [configRepoUrl, setConfigRepoUrl] = useState(""); + const [configRepoBranch, setConfigRepoBranch] = useState(""); const [formData, setFormData] = useState({ name: "", displayName: "", @@ -100,6 +104,8 @@ export function CreateWorkspaceDialog({ displayName: "", description: "", }); + setConfigRepoUrl(""); + setConfigRepoBranch(""); setNameError(null); setError(null); setManuallyEditedName(false); @@ -145,6 +151,18 @@ export function CreateWorkspaceDialog({ createProjectMutation.mutate(payload, { onSuccess: (project) => { + // Save default config repo if provided + if (configRepoUrl.trim()) { + updateSettingsMutation.mutate({ + projectName: project.name, + data: { + defaultConfigRepo: { + gitUrl: configRepoUrl.trim(), + ...(configRepoBranch.trim() && { branch: configRepoBranch.trim() }), + }, + }, + }); + } successToast( `Workspace "${formData.displayName || formData.name}" created successfully` ); @@ -243,6 +261,38 @@ export function CreateWorkspaceDialog({ )}
+ {/* Default Config Repo */} +
+
+ + + (optional) +
+

+ A Git repository containing session configuration (CLAUDE.md, .claude/ rules, .mcp.json) that will be pre-filled for new sessions. +

+
+
+ + setConfigRepoUrl(e.target.value)} + placeholder="https://github.com/org/session-config.git" + /> +
+
+ + setConfigRepoBranch(e.target.value)} + placeholder="main" + /> +
+
+
+ {error && (

{error}

diff --git a/components/frontend/src/components/workspace-sections/settings-section.tsx b/components/frontend/src/components/workspace-sections/settings-section.tsx index e5ecab026..d3231b90e 100644 --- a/components/frontend/src/components/workspace-sections/settings-section.tsx +++ b/components/frontend/src/components/workspace-sections/settings-section.tsx @@ -8,12 +8,13 @@ import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Button } from "@/components/ui/button"; -import { Save, Loader2, Info, AlertTriangle } from "lucide-react"; +import { Save, Loader2, Info, AlertTriangle, GitBranch } from "lucide-react"; import { Plus, Trash2, Eye, EyeOff, ChevronDown, ChevronRight } from "lucide-react"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { successToast, errorToast } from "@/hooks/use-toast"; import { useProject, useUpdateProject } from "@/services/queries/use-projects"; import { useSecretsValues, useUpdateSecrets, useIntegrationSecrets, useUpdateIntegrationSecrets } from "@/services/queries/use-secrets"; +import { useProjectSettings, useUpdateProjectSettings } from "@/services/queries/use-project-settings"; import { useClusterInfo } from "@/hooks/use-cluster-info"; import { useMemo } from "react"; @@ -34,6 +35,8 @@ export function SettingsSection({ projectName }: SettingsSectionProps) { const [s3AccessKey, setS3AccessKey] = useState(""); const [s3SecretKey, setS3SecretKey] = useState(""); const [showS3SecretKey, setShowS3SecretKey] = useState(false); + const [configRepoUrl, setConfigRepoUrl] = useState(""); + const [configRepoBranch, setConfigRepoBranch] = useState(""); const [anthropicExpanded, setAnthropicExpanded] = useState(false); const [s3Expanded, setS3Expanded] = useState(false); const FIXED_KEYS = useMemo(() => ["ANTHROPIC_API_KEY","STORAGE_MODE","S3_ENDPOINT","S3_BUCKET","S3_REGION","S3_ACCESS_KEY","S3_SECRET_KEY"] as const, []); @@ -42,8 +45,10 @@ export function SettingsSection({ projectName }: SettingsSectionProps) { const { data: project, isLoading: projectLoading } = useProject(projectName); const { data: runnerSecrets } = useSecretsValues(projectName); // ambient-runner-secrets (ANTHROPIC_API_KEY) const { data: integrationSecrets } = useIntegrationSecrets(projectName); // ambient-non-vertex-integrations (GITHUB_TOKEN, GIT_USER_*, JIRA_*, custom) + const { data: projectSettings } = useProjectSettings(projectName); const { vertexEnabled } = useClusterInfo(); const updateProjectMutation = useUpdateProject(); + const updateProjectSettingsMutation = useUpdateProjectSettings(); const updateSecretsMutation = useUpdateSecrets(); const updateIntegrationSecretsMutation = useUpdateIntegrationSecrets(); @@ -54,6 +59,14 @@ export function SettingsSection({ projectName }: SettingsSectionProps) { } }, [project]); + // Sync config repo from project settings + useEffect(() => { + if (projectSettings?.defaultConfigRepo) { + setConfigRepoUrl(projectSettings.defaultConfigRepo.gitUrl || ""); + setConfigRepoBranch(projectSettings.defaultConfigRepo.branch || ""); + } + }, [projectSettings]); + // Sync secrets values to state (merge both secrets) useEffect(() => { const allSecrets = [...(runnerSecrets || []), ...(integrationSecrets || [])]; @@ -95,6 +108,27 @@ export function SettingsSection({ projectName }: SettingsSectionProps) { ); }; + const handleSaveConfigRepo = () => { + const trimmedUrl = configRepoUrl.trim(); + updateProjectSettingsMutation.mutate( + { + projectName, + data: { + defaultConfigRepo: trimmedUrl + ? { gitUrl: trimmedUrl, ...(configRepoBranch.trim() && { branch: configRepoBranch.trim() }) } + : { gitUrl: "" }, + }, + }, + { + onSuccess: () => successToast("Default config repo updated"), + onError: (error) => { + const message = error instanceof Error ? error.message : "Failed to update config repo"; + errorToast(message); + }, + } + ); + }; + // Save Anthropic API key separately (ambient-runner-secrets) const handleSaveAnthropicKey = () => { if (!projectName) return; @@ -249,6 +283,56 @@ export function SettingsSection({ projectName }: SettingsSectionProps) { )} + + +
+ + Default Config Repo +
+ + A Git repository containing session configuration (CLAUDE.md, .claude/ rules, .mcp.json) that will be pre-filled for new sessions in this workspace. + +
+ + +
+
+ + setConfigRepoUrl(e.target.value)} + placeholder="https://github.com/org/session-config.git" + /> +
+
+ + setConfigRepoBranch(e.target.value)} + placeholder="main" + /> +
+
+
+ +
+
+
+ Integration Secrets diff --git a/components/frontend/src/services/api/project-settings.ts b/components/frontend/src/services/api/project-settings.ts new file mode 100644 index 000000000..7e6184efd --- /dev/null +++ b/components/frontend/src/services/api/project-settings.ts @@ -0,0 +1,23 @@ +import { apiClient } from "./client"; +import type { + ProjectSettingsCR, + UpdateProjectSettingsRequest, +} from "@/types/project-settings"; + +export async function getProjectSettings( + projectName: string +): Promise { + return apiClient.get( + `/projects/${projectName}/project-settings` + ); +} + +export async function updateProjectSettings( + projectName: string, + data: UpdateProjectSettingsRequest +): Promise { + return apiClient.put( + `/projects/${projectName}/project-settings`, + data + ); +} diff --git a/components/frontend/src/services/queries/use-project-settings.ts b/components/frontend/src/services/queries/use-project-settings.ts new file mode 100644 index 000000000..6a3b1afab --- /dev/null +++ b/components/frontend/src/services/queries/use-project-settings.ts @@ -0,0 +1,30 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import * as projectSettingsApi from "../api/project-settings"; +import type { UpdateProjectSettingsRequest } from "@/types/project-settings"; + +export function useProjectSettings(projectName: string) { + return useQuery({ + queryKey: ["project-settings", projectName], + queryFn: () => projectSettingsApi.getProjectSettings(projectName), + enabled: !!projectName, + }); +} + +export function useUpdateProjectSettings() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + projectName, + data, + }: { + projectName: string; + data: UpdateProjectSettingsRequest; + }) => projectSettingsApi.updateProjectSettings(projectName, data), + onSuccess: (_, { projectName }) => { + queryClient.invalidateQueries({ + queryKey: ["project-settings", projectName], + }); + }, + }); +} diff --git a/components/frontend/src/types/agentic-session.ts b/components/frontend/src/types/agentic-session.ts index 408281d78..9e3e5e68c 100644 --- a/components/frontend/src/types/agentic-session.ts +++ b/components/frontend/src/types/agentic-session.ts @@ -34,6 +34,11 @@ export type AgenticSessionSpec = { branch: string; path?: string; }; + // Session configuration repository (CLAUDE.md, .claude/ rules, skills, agents, .mcp.json) + configRepo?: { + gitUrl: string; + branch?: string; + }; }; export type ReconciledRepo = { @@ -190,6 +195,8 @@ export type CreateAgenticSessionRequest = { interactive?: boolean; // Multi-repo support repos?: SessionRepo[]; + // Session configuration repository + configRepo?: { gitUrl: string; branch?: string }; labels?: Record; annotations?: Record; }; diff --git a/components/frontend/src/types/api/sessions.ts b/components/frontend/src/types/api/sessions.ts index 976de2963..b381212f9 100644 --- a/components/frontend/src/types/api/sessions.ts +++ b/components/frontend/src/types/api/sessions.ts @@ -55,6 +55,10 @@ export type AgenticSessionSpec = { branch: string; path?: string; }; + configRepo?: { + gitUrl: string; + branch?: string; + }; }; export type ReconciledRepo = { @@ -124,6 +128,7 @@ export type CreateAgenticSessionRequest = { environmentVariables?: Record; interactive?: boolean; repos?: SessionRepo[]; + configRepo?: { gitUrl: string; branch?: string }; userContext?: UserContext; labels?: Record; annotations?: Record; diff --git a/components/frontend/src/types/project-settings.ts b/components/frontend/src/types/project-settings.ts index d0aff5cd9..4a13cb5cf 100644 --- a/components/frontend/src/types/project-settings.ts +++ b/components/frontend/src/types/project-settings.ts @@ -46,4 +46,19 @@ export type ProjectSettingsUpdateRequest = { adminUsers: string[]; defaultSettings: ProjectDefaultSettings; resourceLimits: ProjectResourceLimits; +}; + +// Types for the project-settings REST API (backed by ProjectSettings CR) + +export type DefaultConfigRepo = { + gitUrl: string; + branch?: string; +}; + +export type ProjectSettingsCR = { + defaultConfigRepo?: DefaultConfigRepo; +}; + +export type UpdateProjectSettingsRequest = { + defaultConfigRepo?: DefaultConfigRepo; }; \ No newline at end of file diff --git a/components/manifests/base/crds/agenticsessions-crd.yaml b/components/manifests/base/crds/agenticsessions-crd.yaml index 3d71a15b3..3481df944 100644 --- a/components/manifests/base/crds/agenticsessions-crd.yaml +++ b/components/manifests/base/crds/agenticsessions-crd.yaml @@ -93,6 +93,17 @@ spec: path: type: string description: "Optional path within repo (for repos with multiple workflows)" + configRepo: + type: object + description: "Session configuration repository (CLAUDE.md, .claude/ settings, rules, skills, agents, .mcp.json)" + properties: + gitUrl: + type: string + description: "Git repository URL for the session config" + branch: + type: string + description: "Branch to clone" + default: "main" status: type: object properties: diff --git a/components/manifests/base/crds/projectsettings-crd.yaml b/components/manifests/base/crds/projectsettings-crd.yaml index f14fc1219..f1d7077ce 100644 --- a/components/manifests/base/crds/projectsettings-crd.yaml +++ b/components/manifests/base/crds/projectsettings-crd.yaml @@ -42,26 +42,16 @@ spec: runnerSecretsName: type: string description: "Name of the Kubernetes Secret in this namespace that stores runner configuration key/value pairs" - repositories: - type: array - description: "Git repositories configured for this project" - items: - type: object - required: - - url - properties: - url: - type: string - description: "Repository URL (HTTPS or SSH format)" - branch: - type: string - description: "Optional branch override (defaults to repository's default branch)" - provider: - type: string - enum: - - "github" - - "gitlab" - description: "Git hosting provider (auto-detected from URL if not specified)" + defaultConfigRepo: + type: object + description: "Default session-config repository for new sessions (CLAUDE.md, .claude/ rules, skills, agents, .mcp.json)" + properties: + gitUrl: + type: string + description: "Git repository URL" + branch: + type: string + description: "Branch (defaults to main)" status: type: object properties: diff --git a/components/operator/internal/handlers/sessions.go b/components/operator/internal/handlers/sessions.go index 5183c13f6..3296425a1 100644 --- a/components/operator/internal/handlers/sessions.go +++ b/components/operator/internal/handlers/sessions.go @@ -788,6 +788,16 @@ func handleAgenticSessionEvent(obj *unstructured.Unstructured) error { } } + // Add config repo info if present (cloned at init, overlaid into workspace) + if configRepo, ok := spec["configRepo"].(map[string]interface{}); ok { + if gitURL, ok := configRepo["gitUrl"].(string); ok && strings.TrimSpace(gitURL) != "" { + base = append(base, corev1.EnvVar{Name: "CONFIG_REPO_GIT_URL", Value: gitURL}) + } + if branch, ok := configRepo["branch"].(string); ok && strings.TrimSpace(branch) != "" { + base = append(base, corev1.EnvVar{Name: "CONFIG_REPO_BRANCH", Value: branch}) + } + } + // Add GitHub token for private repos secretName := "" if meta, ok := currentObj.Object["metadata"].(map[string]interface{}); ok { @@ -1079,6 +1089,15 @@ func handleAgenticSessionEvent(obj *unstructured.Unstructured) error { base = append(base, corev1.EnvVar{Name: "ACTIVE_WORKFLOW_PATH", Value: path}) } } + // Inject configRepo environment variables if present (cloned at init, overlaid into workspace) + if configRepo, ok := spec["configRepo"].(map[string]interface{}); ok { + if gitURL, ok := configRepo["gitUrl"].(string); ok && strings.TrimSpace(gitURL) != "" { + base = append(base, corev1.EnvVar{Name: "CONFIG_REPO_GIT_URL", Value: gitURL}) + } + if branch, ok := configRepo["branch"].(string); ok && strings.TrimSpace(branch) != "" { + base = append(base, corev1.EnvVar{Name: "CONFIG_REPO_BRANCH", Value: branch}) + } + } if envMap, ok := spec["environmentVariables"].(map[string]interface{}); ok { for k, v := range envMap { if vs, ok := v.(string); ok { diff --git a/components/runners/state-sync/hydrate.sh b/components/runners/state-sync/hydrate.sh index 05f6ee48f..7e4331ce8 100644 --- a/components/runners/state-sync/hydrate.sh +++ b/components/runners/state-sync/hydrate.sh @@ -204,6 +204,51 @@ else echo "No repositories configured in spec" fi +# Clone session-config repository and overlay into workspace +# Config files (CLAUDE.md, .claude/, .mcp.json) are copied first; workspace repo files override later. +if [ -n "$CONFIG_REPO_GIT_URL" ] && [ "$CONFIG_REPO_GIT_URL" != "null" ]; then + CONFIG_BRANCH="${CONFIG_REPO_BRANCH:-main}" + + echo "Cloning session-config repository..." + echo " URL: $CONFIG_REPO_GIT_URL" + echo " Branch: $CONFIG_BRANCH" + + CONFIG_TEMP="/tmp/config-repo-clone-$$" + CONFIG_CLONE_OK=false + + for attempt in 1 2; do + if git clone --branch "$CONFIG_BRANCH" --single-branch --depth 1 "$CONFIG_REPO_GIT_URL" "$CONFIG_TEMP" 2>&1; then + CONFIG_CLONE_OK=true + break + fi + rm -rf "$CONFIG_TEMP" 2>/dev/null || true + if [ "$attempt" -eq 1 ]; then + echo " Retry in 5s..." + sleep 5 + fi + done + + if [ "$CONFIG_CLONE_OK" = true ]; then + echo " Clone successful, overlaying into workspace..." + + # Remove .git directory — we only want the config files + rm -rf "$CONFIG_TEMP/.git" + + # Overlay: copy config files into /workspace root + # cp -rn (no-clobber, GNU coreutils) ensures workspace repo files take precedence + cp -rn "$CONFIG_TEMP"/. /workspace/ 2>/dev/null || true + + rm -rf "$CONFIG_TEMP" + echo " ✓ Session config applied to workspace" + else + echo " ✗ Failed to clone session-config repository after 2 attempts" + echo "Failed to clone session-config repository: $CONFIG_REPO_GIT_URL (branch: $CONFIG_BRANCH)" > /workspace/.config-repo-error + # Non-fatal: session continues without config overlay + fi +else + echo "No session-config repository configured" +fi + # Clone workflow repository if [ -n "$ACTIVE_WORKFLOW_GIT_URL" ] && [ "$ACTIVE_WORKFLOW_GIT_URL" != "null" ]; then WORKFLOW_BRANCH="${ACTIVE_WORKFLOW_BRANCH:-main}" diff --git a/e2e/cypress/e2e/project-settings.cy.ts b/e2e/cypress/e2e/project-settings.cy.ts new file mode 100644 index 000000000..4fb1dbc66 --- /dev/null +++ b/e2e/cypress/e2e/project-settings.cy.ts @@ -0,0 +1,165 @@ +/** + * E2E Tests for Project Settings - Default Config Repo + * + * Tests the project-settings API (GET/PUT) for storing a workspace-level + * default session-config repo that pre-fills into new sessions. + */ +describe('Project Settings - Default Config Repo', () => { + const workspaceName = `e2e-settings-${Date.now()}` + const token = Cypress.env('TEST_TOKEN') + + Cypress.on('uncaught:exception', (err) => { + if (err.message.includes('Minified React error #418') || + err.message.includes('Minified React error #423') || + err.message.includes('Hydration')) { + return false + } + return true + }) + + before(() => { + expect(token, 'TEST_TOKEN should be set').to.exist + + // Create workspace + cy.log(`Creating workspace: ${workspaceName}`) + cy.visit('/projects') + cy.contains('Workspaces', { timeout: 15000 }).should('be.visible') + cy.contains('button', 'New Workspace').click() + cy.contains('Create New Workspace', { timeout: 10000 }).should('be.visible') + cy.get('#name').clear().type(workspaceName) + cy.contains('button', 'Create Workspace').should('not.be.disabled').click({ force: true }) + cy.url({ timeout: 20000 }).should('include', `/projects/${workspaceName}`) + + // Wait for namespace ready + const pollProject = (attempt = 1) => { + if (attempt > 20) throw new Error('Namespace timeout') + cy.request({ + url: `/api/projects/${workspaceName}`, + headers: { 'Authorization': `Bearer ${token}` }, + failOnStatusCode: false + }).then((response) => { + if (response.status === 200) { + cy.log(`Namespace ready after ${attempt} attempts`) + } else { + cy.wait(1000, { log: false }) + pollProject(attempt + 1) + } + }) + } + pollProject() + }) + + after(() => { + if (!Cypress.env('KEEP_WORKSPACES')) { + cy.request({ + method: 'DELETE', + url: `/api/projects/${workspaceName}`, + headers: { 'Authorization': `Bearer ${token}` }, + failOnStatusCode: false + }) + } + }) + + it('should return empty settings for a new workspace', () => { + cy.request({ + url: `/api/projects/${workspaceName}/project-settings`, + headers: { 'Authorization': `Bearer ${token}` }, + }).then((response) => { + expect(response.status).to.eq(200) + expect(response.body).to.not.have.property('defaultConfigRepo') + }) + }) + + it('should save and retrieve default config repo', () => { + // PUT config repo + cy.request({ + method: 'PUT', + url: `/api/projects/${workspaceName}/project-settings`, + headers: { 'Authorization': `Bearer ${token}` }, + body: { + defaultConfigRepo: { + gitUrl: 'https://github.com/example/session-config.git', + branch: 'develop', + } + } + }).then((response) => { + expect(response.status).to.eq(200) + expect(response.body.defaultConfigRepo.gitUrl).to.eq('https://github.com/example/session-config.git') + expect(response.body.defaultConfigRepo.branch).to.eq('develop') + }) + + // GET and verify persistence + cy.request({ + url: `/api/projects/${workspaceName}/project-settings`, + headers: { 'Authorization': `Bearer ${token}` }, + }).then((response) => { + expect(response.status).to.eq(200) + expect(response.body.defaultConfigRepo.gitUrl).to.eq('https://github.com/example/session-config.git') + expect(response.body.defaultConfigRepo.branch).to.eq('develop') + }) + }) + + it('should clear config repo when gitUrl is empty', () => { + // Set it first + cy.request({ + method: 'PUT', + url: `/api/projects/${workspaceName}/project-settings`, + headers: { 'Authorization': `Bearer ${token}` }, + body: { + defaultConfigRepo: { + gitUrl: 'https://github.com/example/to-be-cleared.git', + } + } + }) + + // Clear it + cy.request({ + method: 'PUT', + url: `/api/projects/${workspaceName}/project-settings`, + headers: { 'Authorization': `Bearer ${token}` }, + body: { + defaultConfigRepo: { + gitUrl: '', + } + } + }).then((response) => { + expect(response.status).to.eq(200) + expect(response.body).to.not.have.property('defaultConfigRepo') + }) + + // Verify cleared + cy.request({ + url: `/api/projects/${workspaceName}/project-settings`, + headers: { 'Authorization': `Bearer ${token}` }, + }).then((response) => { + expect(response.status).to.eq(200) + expect(response.body).to.not.have.property('defaultConfigRepo') + }) + }) + + it('should pre-fill config repo in Create Session dialog', () => { + // Set default config repo via API + cy.request({ + method: 'PUT', + url: `/api/projects/${workspaceName}/project-settings`, + headers: { 'Authorization': `Bearer ${token}` }, + body: { + defaultConfigRepo: { + gitUrl: 'https://github.com/example/prefill-test.git', + branch: 'staging', + } + } + }) + + // Navigate to workspace and open Create Session dialog + cy.visit(`/projects/${workspaceName}`) + cy.contains('button', 'New Session', { timeout: 15000 }).click() + cy.contains('Create Session', { timeout: 10000 }).should('be.visible') + + // Verify config repo fields are pre-filled + cy.get('input[placeholder*="github.com/org/session-config"]', { timeout: 5000 }) + .should('have.value', 'https://github.com/example/prefill-test.git') + cy.get('input[placeholder="main"]') + .should('have.value', 'staging') + }) +})