From 8bbe821606a382c2164d9f6679ed8701058712eb Mon Sep 17 00:00:00 2001 From: Sylvain Bauza Date: Thu, 26 Mar 2026 11:20:10 +0100 Subject: [PATCH 1/6] feat: add Gerrit integration connector for code review workflows Add a complete Gerrit integration across backend, frontend, and runner components, following the existing integration patterns (Jira, GitLab). Backend (Go): - Gerrit credential CRUD handlers (connect, status, disconnect, list) - Validation against Gerrit /a/accounts/self endpoint - K8s Secret storage with compound key (instanceName.userID) - Session-scoped credential endpoint for runner consumption - Integration status reporting - Unit tests for auth handler input validation Frontend (TypeScript/React): - Connection card with HTTP basic / gitcookies auth toggle - Multi-instance management with individual disconnect - React Query hooks with proper cache invalidation - Next.js API proxy routes for all Gerrit endpoints - Session integrations panel entry Runner (Python): - Gerrit credential fetching from backend API - Runtime config generation (gerrit_config.json + gitcookies file) - MCP server entry in .mcp.json using Python 3.12 venv - Dockerfile changes for Python 3.12 + Gerrit MCP server build - Credential cleanup on session end Documentation: - Gerrit integration guide (docs/internal/integrations/) - Updated integrations README with Gerrit section - OpenAPI contract spec - Design specs and implementation plan Co-Authored-By: Claude Opus 4.6 --- components/backend/handlers/gerrit_auth.go | 388 +++++++++++++++ .../backend/handlers/gerrit_auth_test.go | 416 ++++++++++++++++ .../handlers/integration_validation.go | 116 +++++ .../backend/handlers/integrations_status.go | 28 ++ .../backend/handlers/runtime_credentials.go | 49 ++ components/backend/routes.go | 11 +- .../gerrit/[instanceName]/disconnect/route.ts | 18 + .../gerrit/[instanceName]/status/route.ts | 18 + .../src/app/api/auth/gerrit/connect/route.ts | 16 + .../app/api/auth/gerrit/instances/route.ts | 14 + .../src/app/api/auth/gerrit/test/route.ts | 16 + .../app/integrations/IntegrationsClient.tsx | 5 + .../settings/integrations-panel.tsx | 10 + .../src/components/gerrit-connection-card.tsx | 341 +++++++++++++ .../frontend/src/services/api/gerrit-auth.ts | 63 +++ .../frontend/src/services/api/integrations.ts | 9 + .../src/services/queries/use-gerrit.ts | 43 ++ components/runners/ambient-runner/.mcp.json | 8 + components/runners/ambient-runner/Dockerfile | 25 + .../ambient_runner/bridges/claude/mcp.py | 109 ++++- .../ambient_runner/platform/auth.py | 85 +++- docs/internal/integrations/README.md | 18 + .../integrations/gerrit-integration.md | 456 ++++++++++++++++++ .../checklists/requirements.md | 37 ++ .../contracts/frontend-types.ts | 48 ++ .../contracts/gerrit-api.yaml | 266 ++++++++++ specs/001-gerrit-integration/data-model.md | 107 ++++ specs/001-gerrit-integration/plan.md | 140 ++++++ specs/001-gerrit-integration/quickstart.md | 94 ++++ specs/001-gerrit-integration/research.md | 136 ++++++ specs/001-gerrit-integration/spec.md | 138 ++++++ specs/001-gerrit-integration/tasks.md | 218 +++++++++ 32 files changed, 3431 insertions(+), 15 deletions(-) create mode 100644 components/backend/handlers/gerrit_auth.go create mode 100644 components/backend/handlers/gerrit_auth_test.go mode change 100755 => 100644 components/backend/handlers/runtime_credentials.go create mode 100644 components/frontend/src/app/api/auth/gerrit/[instanceName]/disconnect/route.ts create mode 100644 components/frontend/src/app/api/auth/gerrit/[instanceName]/status/route.ts create mode 100644 components/frontend/src/app/api/auth/gerrit/connect/route.ts create mode 100644 components/frontend/src/app/api/auth/gerrit/instances/route.ts create mode 100644 components/frontend/src/app/api/auth/gerrit/test/route.ts create mode 100644 components/frontend/src/components/gerrit-connection-card.tsx create mode 100644 components/frontend/src/services/api/gerrit-auth.ts create mode 100644 components/frontend/src/services/queries/use-gerrit.ts mode change 100755 => 100644 components/runners/ambient-runner/ambient_runner/platform/auth.py create mode 100644 docs/internal/integrations/gerrit-integration.md create mode 100644 specs/001-gerrit-integration/checklists/requirements.md create mode 100644 specs/001-gerrit-integration/contracts/frontend-types.ts create mode 100644 specs/001-gerrit-integration/contracts/gerrit-api.yaml create mode 100644 specs/001-gerrit-integration/data-model.md create mode 100644 specs/001-gerrit-integration/plan.md create mode 100644 specs/001-gerrit-integration/quickstart.md create mode 100644 specs/001-gerrit-integration/research.md create mode 100644 specs/001-gerrit-integration/spec.md create mode 100644 specs/001-gerrit-integration/tasks.md diff --git a/components/backend/handlers/gerrit_auth.go b/components/backend/handlers/gerrit_auth.go new file mode 100644 index 000000000..6874917e9 --- /dev/null +++ b/components/backend/handlers/gerrit_auth.go @@ -0,0 +1,388 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "regexp" + "strings" + "time" + + "github.com/gin-gonic/gin" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GerritCredentials represents cluster-level Gerrit credentials for a user instance +type GerritCredentials struct { + UserID string `json:"userId"` + InstanceName string `json:"instanceName"` // User-assigned name (e.g., "openstack") + URL string `json:"url"` // Gerrit instance base URL + AuthMethod string `json:"authMethod"` // "http_basic" or "git_cookies" + Username string `json:"username,omitempty"` // For http_basic + HTTPToken string `json:"httpToken,omitempty"` // For http_basic + GitcookiesContent string `json:"gitcookiesContent,omitempty"` // For git_cookies + UpdatedAt time.Time `json:"updatedAt"` +} + +const gerritSecretName = "gerrit-credentials" + +var validInstanceNameRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$`) + +// gerritSecretKey returns the K8s secret data key for a Gerrit instance +func gerritSecretKey(instanceName, userID string) string { + return instanceName + "." + userID +} + +// ConnectGerrit handles POST /api/auth/gerrit/connect +// Validates and stores Gerrit credentials for a user instance +func ConnectGerrit(c *gin.Context) { + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + return + } + + userID := c.GetString("userID") + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User authentication required"}) + return + } + if !isValidUserID(userID) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user identifier"}) + return + } + + var req struct { + InstanceName string `json:"instanceName" binding:"required"` + URL string `json:"url" binding:"required"` + AuthMethod string `json:"authMethod" binding:"required"` + Username string `json:"username"` + HTTPToken string `json:"httpToken"` + GitcookiesContent string `json:"gitcookiesContent"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Validate instance name format + if !validInstanceNameRegex.MatchString(req.InstanceName) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Instance name must be lowercase alphanumeric with hyphens (2-63 chars)"}) + return + } + + // Validate auth method and required fields + switch req.AuthMethod { + case "http_basic": + if req.Username == "" || req.HTTPToken == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Username and HTTP token are required for HTTP basic auth"}) + return + } + case "git_cookies": + if req.GitcookiesContent == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Gitcookies content is required for git_cookies auth"}) + return + } + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "Auth method must be 'http_basic' or 'git_cookies'"}) + return + } + + // Validate credentials against the Gerrit instance + valid, err := ValidateGerritToken(c.Request.Context(), req.URL, req.AuthMethod, req.Username, req.HTTPToken, req.GitcookiesContent) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Validation failed: %s", err.Error())}) + return + } + if !valid { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Gerrit credentials"}) + return + } + + creds := &GerritCredentials{ + UserID: userID, + InstanceName: req.InstanceName, + URL: req.URL, + AuthMethod: req.AuthMethod, + Username: req.Username, + HTTPToken: req.HTTPToken, + GitcookiesContent: req.GitcookiesContent, + UpdatedAt: time.Now(), + } + + if err := storeGerritCredentials(c.Request.Context(), creds); err != nil { + log.Printf("Failed to store Gerrit credentials for user %s instance %s: %v", userID, req.InstanceName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save Gerrit credentials"}) + return + } + + log.Printf("✓ Stored Gerrit credentials for user %s instance %s", userID, req.InstanceName) + c.JSON(http.StatusOK, gin.H{ + "message": fmt.Sprintf("Gerrit instance '%s' connected successfully", req.InstanceName), + "instanceName": req.InstanceName, + "url": req.URL, + "authMethod": req.AuthMethod, + }) +} + +// GetGerritStatus handles GET /api/auth/gerrit/:instanceName/status +func GetGerritStatus(c *gin.Context) { + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + return + } + + userID := c.GetString("userID") + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User authentication required"}) + return + } + + instanceName := c.Param("instanceName") + if instanceName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Instance name is required"}) + return + } + + creds, err := getGerritCredentials(c.Request.Context(), instanceName, userID) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusOK, gin.H{"connected": false}) + return + } + log.Printf("Failed to get Gerrit credentials for user %s instance %s: %v", userID, instanceName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check Gerrit status"}) + return + } + + if creds == nil { + c.JSON(http.StatusOK, gin.H{"connected": false}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "connected": true, + "instanceName": creds.InstanceName, + "url": creds.URL, + "authMethod": creds.AuthMethod, + "updatedAt": creds.UpdatedAt.Format(time.RFC3339), + }) +} + +// DisconnectGerrit handles DELETE /api/auth/gerrit/:instanceName/disconnect +func DisconnectGerrit(c *gin.Context) { + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + return + } + + userID := c.GetString("userID") + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User authentication required"}) + return + } + + instanceName := c.Param("instanceName") + if instanceName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Instance name is required"}) + return + } + + if err := deleteGerritCredentials(c.Request.Context(), instanceName, userID); err != nil { + log.Printf("Failed to delete Gerrit credentials for user %s instance %s: %v", userID, instanceName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to disconnect Gerrit"}) + return + } + + log.Printf("✓ Deleted Gerrit credentials for user %s instance %s", userID, instanceName) + c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Gerrit instance '%s' disconnected successfully", instanceName)}) +} + +// ListGerritInstances handles GET /api/auth/gerrit/instances +// Returns all connected Gerrit instances for the authenticated user +func ListGerritInstances(c *gin.Context) { + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + return + } + + userID := c.GetString("userID") + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User authentication required"}) + return + } + + instances, err := listGerritCredentials(c.Request.Context(), userID) + if err != nil { + log.Printf("Failed to list Gerrit instances for user %s: %v", userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list Gerrit instances"}) + return + } + + result := make([]gin.H, 0, len(instances)) + for _, creds := range instances { + result = append(result, gin.H{ + "connected": true, + "instanceName": creds.InstanceName, + "url": creds.URL, + "authMethod": creds.AuthMethod, + "updatedAt": creds.UpdatedAt.Format(time.RFC3339), + }) + } + + c.JSON(http.StatusOK, gin.H{"instances": result}) +} + +// storeGerritCredentials stores Gerrit credentials in cluster-level Secret +func storeGerritCredentials(ctx context.Context, creds *GerritCredentials) error { + if creds == nil || creds.UserID == "" || creds.InstanceName == "" { + return fmt.Errorf("invalid credentials payload") + } + + key := gerritSecretKey(creds.InstanceName, creds.UserID) + + for i := 0; i < 3; i++ { // retry on conflict + secret, err := K8sClient.CoreV1().Secrets(Namespace).Get(ctx, gerritSecretName, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + secret = &corev1.Secret{ + ObjectMeta: v1.ObjectMeta{ + Name: gerritSecretName, + Namespace: Namespace, + Labels: map[string]string{ + "app": "ambient-code", + "ambient-code.io/provider": "gerrit", + }, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{}, + } + if _, cerr := K8sClient.CoreV1().Secrets(Namespace).Create(ctx, secret, v1.CreateOptions{}); cerr != nil && !errors.IsAlreadyExists(cerr) { + return fmt.Errorf("failed to create Secret: %w", cerr) + } + secret, err = K8sClient.CoreV1().Secrets(Namespace).Get(ctx, gerritSecretName, v1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to fetch Secret after create: %w", err) + } + } else { + return fmt.Errorf("failed to get Secret: %w", err) + } + } + + if secret.Data == nil { + secret.Data = map[string][]byte{} + } + + b, err := json.Marshal(creds) + if err != nil { + return fmt.Errorf("failed to marshal credentials: %w", err) + } + secret.Data[key] = b + + if _, uerr := K8sClient.CoreV1().Secrets(Namespace).Update(ctx, secret, v1.UpdateOptions{}); uerr != nil { + if errors.IsConflict(uerr) { + continue + } + return fmt.Errorf("failed to update Secret: %w", uerr) + } + return nil + } + return fmt.Errorf("failed to update Secret after retries") +} + +// getGerritCredentials retrieves Gerrit credentials for a specific instance and user +func getGerritCredentials(ctx context.Context, instanceName, userID string) (*GerritCredentials, error) { + if userID == "" || instanceName == "" { + return nil, fmt.Errorf("userID and instanceName are required") + } + + secret, err := K8sClient.CoreV1().Secrets(Namespace).Get(ctx, gerritSecretName, v1.GetOptions{}) + if err != nil { + return nil, err + } + + key := gerritSecretKey(instanceName, userID) + if secret.Data == nil || len(secret.Data[key]) == 0 { + return nil, nil + } + + var creds GerritCredentials + if err := json.Unmarshal(secret.Data[key], &creds); err != nil { + return nil, fmt.Errorf("failed to parse credentials: %w", err) + } + + return &creds, nil +} + +// listGerritCredentials returns all Gerrit instances for a user +func listGerritCredentials(ctx context.Context, userID string) ([]*GerritCredentials, error) { + if userID == "" { + return nil, fmt.Errorf("userID is required") + } + + secret, err := K8sClient.CoreV1().Secrets(Namespace).Get(ctx, gerritSecretName, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return nil, nil + } + return nil, err + } + + suffix := "." + userID + var result []*GerritCredentials + for key, val := range secret.Data { + if strings.HasSuffix(key, suffix) { + var creds GerritCredentials + if err := json.Unmarshal(val, &creds); err != nil { + log.Printf("Failed to parse Gerrit credentials for key %s: %v", key, err) + continue + } + result = append(result, &creds) + } + } + + return result, nil +} + +// deleteGerritCredentials removes Gerrit credentials for a specific instance and user +func deleteGerritCredentials(ctx context.Context, instanceName, userID string) error { + if userID == "" || instanceName == "" { + return fmt.Errorf("userID and instanceName are required") + } + + key := gerritSecretKey(instanceName, userID) + + for i := 0; i < 3; i++ { // retry on conflict + secret, err := K8sClient.CoreV1().Secrets(Namespace).Get(ctx, gerritSecretName, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return nil + } + return fmt.Errorf("failed to get Secret: %w", err) + } + + if secret.Data == nil || len(secret.Data[key]) == 0 { + return nil + } + + delete(secret.Data, key) + + if _, uerr := K8sClient.CoreV1().Secrets(Namespace).Update(ctx, secret, v1.UpdateOptions{}); uerr != nil { + if errors.IsConflict(uerr) { + continue + } + return fmt.Errorf("failed to update Secret: %w", uerr) + } + return nil + } + return fmt.Errorf("failed to update Secret after retries") +} + diff --git a/components/backend/handlers/gerrit_auth_test.go b/components/backend/handlers/gerrit_auth_test.go new file mode 100644 index 000000000..c91811ef7 --- /dev/null +++ b/components/backend/handlers/gerrit_auth_test.go @@ -0,0 +1,416 @@ +//go:build test + +package handlers + +import ( + "ambient-code-backend/tests/config" + test_constants "ambient-code-backend/tests/constants" + "context" + "net/http" + + "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" +) + +var _ = Describe("Gerrit Auth Handler", Label(test_constants.LabelUnit, test_constants.LabelHandlers, "gerrit-auth"), func() { + var ( + httpUtils *test_utils.HTTPTestUtils + k8sUtils *test_utils.K8sTestUtils + originalNamespace string + testToken string + ) + + BeforeEach(func() { + logger.Log("Setting up Gerrit Auth Handler test") + + originalNamespace = Namespace + + // Use centralized handler dependencies setup + k8sUtils = test_utils.NewK8sTestUtils(false, *config.TestNamespace) + SetupHandlerDependencies(k8sUtils) + + // gerrit_auth.go uses Namespace (backend namespace) for secret operations + Namespace = *config.TestNamespace + + httpUtils = test_utils.NewHTTPTestUtils() + + // Create namespace + role and mint a valid test token for this suite + ctx := context.Background() + _, err := k8sUtils.K8sClient.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: *config.TestNamespace}, + }, metav1.CreateOptions{}) + if err != nil && !errors.IsAlreadyExists(err) { + Expect(err).NotTo(HaveOccurred()) + } + _, err = k8sUtils.CreateTestRole(ctx, *config.TestNamespace, "test-full-access-role", []string{"get", "list", "create", "update", "delete", "patch"}, "*", "") + Expect(err).NotTo(HaveOccurred()) + + token, _, err := httpUtils.SetValidTestToken( + k8sUtils, + *config.TestNamespace, + []string{"get", "list", "create", "update", "delete", "patch"}, + "*", + "", + "test-full-access-role", + ) + Expect(err).NotTo(HaveOccurred()) + testToken = token + }) + + AfterEach(func() { + Namespace = originalNamespace + + // Clean up created namespace (best-effort) + if k8sUtils != nil { + _ = k8sUtils.K8sClient.CoreV1().Namespaces().Delete(context.Background(), *config.TestNamespace, metav1.DeleteOptions{}) + } + }) + + Context("ConnectGerrit", func() { + It("Should require authentication", func() { + requestBody := map[string]interface{}{ + "instanceName": "my-gerrit", + "url": "https://review.opendev.org", + "authMethod": "http_basic", + "username": "testuser", + "httpToken": "secret-token", + } + + context := httpUtils.CreateTestGinContext("POST", "/auth/gerrit/connect", requestBody) + // Don't set auth header + httpUtils.SetUserContext("test-user", "Test User", "test@example.com") + + ConnectGerrit(context) + + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + httpUtils.AssertErrorMessage("Invalid or missing token") + }) + + It("Should require user authentication", func() { + requestBody := map[string]interface{}{ + "instanceName": "my-gerrit", + "url": "https://review.opendev.org", + "authMethod": "http_basic", + "username": "testuser", + "httpToken": "secret-token", + } + + context := httpUtils.CreateTestGinContext("POST", "/auth/gerrit/connect", requestBody) + // Don't set user context + + ConnectGerrit(context) + + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + httpUtils.AssertJSONContains(map[string]interface{}{ + "error": "Invalid or missing token", + }) + }) + + It("Should require valid JSON body", func() { + context := httpUtils.CreateTestGinContext("POST", "/auth/gerrit/connect", "invalid-json") + httpUtils.SetAuthHeader(testToken) + httpUtils.SetUserContext("test-user", "Test User", "test@example.com") + + ConnectGerrit(context) + + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + }) + + It("Should require instanceName field", func() { + requestBody := map[string]interface{}{ + "url": "https://review.opendev.org", + "authMethod": "http_basic", + "username": "testuser", + "httpToken": "secret-token", + } + + context := httpUtils.CreateTestGinContext("POST", "/auth/gerrit/connect", requestBody) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetUserContext("test-user", "Test User", "test@example.com") + + ConnectGerrit(context) + + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + }) + + It("Should require url field", func() { + requestBody := map[string]interface{}{ + "instanceName": "my-gerrit", + "authMethod": "http_basic", + "username": "testuser", + "httpToken": "secret-token", + } + + context := httpUtils.CreateTestGinContext("POST", "/auth/gerrit/connect", requestBody) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetUserContext("test-user", "Test User", "test@example.com") + + ConnectGerrit(context) + + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + }) + + It("Should require authMethod field", func() { + requestBody := map[string]interface{}{ + "instanceName": "my-gerrit", + "url": "https://review.opendev.org", + "username": "testuser", + "httpToken": "secret-token", + } + + context := httpUtils.CreateTestGinContext("POST", "/auth/gerrit/connect", requestBody) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetUserContext("test-user", "Test User", "test@example.com") + + ConnectGerrit(context) + + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + }) + + It("Should reject invalid instance names", func() { + invalidNames := []string{ + "a", // too short (single char) + "INVALID", // uppercase + "my@name", // special characters + } + + for _, name := range invalidNames { + requestBody := map[string]interface{}{ + "instanceName": name, + "url": "https://review.opendev.org", + "authMethod": "http_basic", + "username": "testuser", + "httpToken": "secret-token", + } + + context := httpUtils.CreateTestGinContext("POST", "/auth/gerrit/connect", requestBody) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetUserContext("test-user", "Test User", "test@example.com") + + ConnectGerrit(context) + + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + httpUtils.AssertErrorMessage("Instance name must be lowercase alphanumeric with hyphens (2-63 chars)") + + // Reset for next test + httpUtils = test_utils.NewHTTPTestUtils() + } + }) + + It("Should accept valid instance names", func() { + validNames := []string{ + "my-gerrit", + "openstack", + "review-01", + "a1", + } + + for _, name := range validNames { + requestBody := map[string]interface{}{ + "instanceName": name, + "url": "https://review.opendev.org", + "authMethod": "http_basic", + "username": "testuser", + "httpToken": "secret-token", + } + + context := httpUtils.CreateTestGinContext("POST", "/auth/gerrit/connect", requestBody) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetUserContext("test-user", "Test User", "test@example.com") + + ConnectGerrit(context) + + // Should not fail at instance name validation stage + status := httpUtils.GetResponseRecorder().Code + Expect(status).NotTo(Equal(http.StatusBadRequest), "Should accept valid instance name: "+name) + + // Reset for next test + httpUtils = test_utils.NewHTTPTestUtils() + } + }) + + It("Should reject invalid auth method", func() { + requestBody := map[string]interface{}{ + "instanceName": "my-gerrit", + "url": "https://review.opendev.org", + "authMethod": "oauth2", + "username": "testuser", + "httpToken": "secret-token", + } + + context := httpUtils.CreateTestGinContext("POST", "/auth/gerrit/connect", requestBody) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetUserContext("test-user", "Test User", "test@example.com") + + ConnectGerrit(context) + + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + httpUtils.AssertErrorMessage("Auth method must be 'http_basic' or 'git_cookies'") + }) + + It("Should require username and httpToken for http_basic", func() { + // Missing both username and httpToken + requestBody := map[string]interface{}{ + "instanceName": "my-gerrit", + "url": "https://review.opendev.org", + "authMethod": "http_basic", + } + + context := httpUtils.CreateTestGinContext("POST", "/auth/gerrit/connect", requestBody) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetUserContext("test-user", "Test User", "test@example.com") + + ConnectGerrit(context) + + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + httpUtils.AssertErrorMessage("Username and HTTP token are required for HTTP basic auth") + }) + + It("Should require username for http_basic when only httpToken provided", func() { + requestBody := map[string]interface{}{ + "instanceName": "my-gerrit", + "url": "https://review.opendev.org", + "authMethod": "http_basic", + "httpToken": "secret-token", + } + + context := httpUtils.CreateTestGinContext("POST", "/auth/gerrit/connect", requestBody) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetUserContext("test-user", "Test User", "test@example.com") + + ConnectGerrit(context) + + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + httpUtils.AssertErrorMessage("Username and HTTP token are required for HTTP basic auth") + }) + + It("Should require httpToken for http_basic when only username provided", func() { + requestBody := map[string]interface{}{ + "instanceName": "my-gerrit", + "url": "https://review.opendev.org", + "authMethod": "http_basic", + "username": "testuser", + } + + context := httpUtils.CreateTestGinContext("POST", "/auth/gerrit/connect", requestBody) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetUserContext("test-user", "Test User", "test@example.com") + + ConnectGerrit(context) + + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + httpUtils.AssertErrorMessage("Username and HTTP token are required for HTTP basic auth") + }) + + It("Should require gitcookiesContent for git_cookies", func() { + requestBody := map[string]interface{}{ + "instanceName": "my-gerrit", + "url": "https://review.opendev.org", + "authMethod": "git_cookies", + } + + context := httpUtils.CreateTestGinContext("POST", "/auth/gerrit/connect", requestBody) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetUserContext("test-user", "Test User", "test@example.com") + + ConnectGerrit(context) + + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + httpUtils.AssertErrorMessage("Gitcookies content is required for git_cookies auth") + }) + }) + + Context("GetGerritStatus", func() { + It("Should require authentication", func() { + context := httpUtils.CreateTestGinContext("GET", "/auth/gerrit/openstack/status", nil) + context.Params = gin.Params{ + gin.Param{Key: "instanceName", Value: "openstack"}, + } + // Don't set auth header + httpUtils.SetUserContext("test-user", "Test User", "test@example.com") + + GetGerritStatus(context) + + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + httpUtils.AssertErrorMessage("Invalid or missing token") + }) + + It("Should require user authentication", func() { + context := httpUtils.CreateTestGinContext("GET", "/auth/gerrit/openstack/status", nil) + context.Params = gin.Params{ + gin.Param{Key: "instanceName", Value: "openstack"}, + } + // Don't set user context + + GetGerritStatus(context) + + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + httpUtils.AssertJSONContains(map[string]interface{}{ + "error": "Invalid or missing token", + }) + }) + }) + + Context("DisconnectGerrit", func() { + It("Should require authentication", func() { + context := httpUtils.CreateTestGinContext("DELETE", "/auth/gerrit/openstack/disconnect", nil) + context.Params = gin.Params{ + gin.Param{Key: "instanceName", Value: "openstack"}, + } + // Don't set auth header + httpUtils.SetUserContext("test-user", "Test User", "test@example.com") + + DisconnectGerrit(context) + + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + httpUtils.AssertErrorMessage("Invalid or missing token") + }) + + It("Should require user authentication", func() { + context := httpUtils.CreateTestGinContext("DELETE", "/auth/gerrit/openstack/disconnect", nil) + context.Params = gin.Params{ + gin.Param{Key: "instanceName", Value: "openstack"}, + } + // Don't set user context + + DisconnectGerrit(context) + + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + httpUtils.AssertJSONContains(map[string]interface{}{ + "error": "Invalid or missing token", + }) + }) + }) + + Context("ListGerritInstances", func() { + It("Should require authentication", func() { + context := httpUtils.CreateTestGinContext("GET", "/auth/gerrit/instances", nil) + // Don't set auth header + httpUtils.SetUserContext("test-user", "Test User", "test@example.com") + + ListGerritInstances(context) + + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + httpUtils.AssertErrorMessage("Invalid or missing token") + }) + + It("Should require user authentication", func() { + context := httpUtils.CreateTestGinContext("GET", "/auth/gerrit/instances", nil) + // Don't set user context + + ListGerritInstances(context) + + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + httpUtils.AssertJSONContains(map[string]interface{}{ + "error": "Invalid or missing token", + }) + }) + }) +}) diff --git a/components/backend/handlers/integration_validation.go b/components/backend/handlers/integration_validation.go index 7b034e75e..28fdeba9c 100644 --- a/components/backend/handlers/integration_validation.go +++ b/components/backend/handlers/integration_validation.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "net/http" + "net/url" + "strings" "time" "github.com/gin-gonic/gin" @@ -143,6 +145,120 @@ func ValidateGoogleToken(ctx context.Context, accessToken string) (bool, error) return resp.StatusCode == http.StatusOK, nil } +// ValidateGerritToken checks if Gerrit credentials are valid +// Uses /a/accounts/self endpoint which accepts Basic Auth or Cookie-based auth +// Gerrit REST API responses are prefixed with )]}' (XSSI protection) +func ValidateGerritToken(ctx context.Context, gerritURL, authMethod, username, httpToken, gitcookiesContent string) (bool, error) { + if gerritURL == "" { + return false, fmt.Errorf("Gerrit URL is required") + } + + client := &http.Client{Timeout: 15 * time.Second} + apiURL := fmt.Sprintf("%s/a/accounts/self", gerritURL) + + req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) + if err != nil { + return false, fmt.Errorf("failed to create request") + } + req.Header.Set("Accept", "application/json") + + switch authMethod { + case "http_basic": + if username == "" || httpToken == "" { + return false, fmt.Errorf("username and HTTP token are required for HTTP basic auth") + } + req.SetBasicAuth(username, httpToken) + + case "git_cookies": + if gitcookiesContent == "" { + return false, fmt.Errorf("gitcookies content is required") + } + // Parse gitcookies content to extract cookie for the target host + cookie := parseGitcookies(gerritURL, gitcookiesContent) + if cookie == "" { + return false, fmt.Errorf("no matching cookie found for host in gitcookies content") + } + req.Header.Set("Cookie", cookie) + + default: + return false, fmt.Errorf("unsupported auth method: %s", authMethod) + } + + resp, err := client.Do(req) + if err != nil { + // Don't wrap error - could leak credentials from request details + return false, fmt.Errorf("request failed") + } + defer resp.Body.Close() + + // 200 = valid, 401/403 = invalid + if resp.StatusCode == http.StatusOK { + return true, nil + } + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return false, nil + } + + // Other status codes - can't validate, assume valid to avoid false negatives + return true, nil +} + +// parseGitcookies extracts the cookie value for a given Gerrit URL from gitcookies content. +// Gitcookies format: host\tFALSE\t/\tTRUE\t2147483647\to\tvalue +func parseGitcookies(gerritURL, content string) string { + parsed, err := url.Parse(gerritURL) + if err != nil { + return "" + } + host := parsed.Hostname() + + lines := strings.Split(content, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + fields := strings.Split(line, "\t") + if len(fields) >= 7 { + cookieHost := strings.TrimPrefix(fields[0], ".") + if cookieHost == host || strings.HasSuffix(host, "."+cookieHost) { + return fields[5] + "=" + fields[6] + } + } + } + return "" +} + +// TestGerritConnection handles POST /api/auth/gerrit/test +// Tests Gerrit credentials without saving them +func TestGerritConnection(c *gin.Context) { + var req struct { + URL string `json:"url" binding:"required"` + AuthMethod string `json:"authMethod" binding:"required"` + Username string `json:"username"` + HTTPToken string `json:"httpToken"` + GitcookiesContent string `json:"gitcookiesContent"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + valid, err := ValidateGerritToken(c.Request.Context(), req.URL, req.AuthMethod, req.Username, req.HTTPToken, req.GitcookiesContent) + if err != nil { + c.JSON(http.StatusOK, gin.H{"valid": false, "error": err.Error()}) + return + } + + if !valid { + c.JSON(http.StatusOK, gin.H{"valid": false, "error": "Invalid credentials"}) + return + } + + c.JSON(http.StatusOK, gin.H{"valid": true, "message": "Gerrit connection successful"}) +} + // TestJiraConnection handles POST /api/auth/jira/test // Tests Jira credentials without saving them func TestJiraConnection(c *gin.Context) { diff --git a/components/backend/handlers/integrations_status.go b/components/backend/handlers/integrations_status.go index 36e992c59..f12558500 100644 --- a/components/backend/handlers/integrations_status.go +++ b/components/backend/handlers/integrations_status.go @@ -39,6 +39,9 @@ func GetIntegrationsStatus(c *gin.Context) { // GitLab status response["gitlab"] = getGitLabStatusForUser(ctx, userID) + // Gerrit status + response["gerrit"] = getGerritStatusForUser(ctx, userID) + // MCP server credentials status response["mcpServers"] = getMCPServerStatusForUser(ctx, userID) @@ -47,6 +50,7 @@ func GetIntegrationsStatus(c *gin.Context) { // Helper functions to get individual integration statuses +// getGitHubStatusForUser returns the GitHub integration status (App + PAT) for a user. func getGitHubStatusForUser(ctx context.Context, userID string) gin.H { log.Printf("getGitHubStatusForUser: querying status for user=%s", userID) status := gin.H{ @@ -90,6 +94,7 @@ func getGitHubStatusForUser(ctx context.Context, userID string) gin.H { return status } +// getGoogleStatusForUser returns the Google OAuth integration status for a user. func getGoogleStatusForUser(ctx context.Context, userID string) gin.H { creds, err := GetGoogleCredentials(ctx, userID) if err != nil || creds == nil { @@ -108,6 +113,7 @@ func getGoogleStatusForUser(ctx context.Context, userID string) gin.H { } } +// getJiraStatusForUser returns the Jira integration status for a user. func getJiraStatusForUser(ctx context.Context, userID string) gin.H { creds, err := GetJiraCredentials(ctx, userID) if err != nil || creds == nil { @@ -128,6 +134,28 @@ func getJiraStatusForUser(ctx context.Context, userID string) gin.H { } } +// getGerritStatusForUser returns the Gerrit integration status with all connected instances for a user. +func getGerritStatusForUser(ctx context.Context, userID string) gin.H { + instances, err := listGerritCredentials(ctx, userID) + if err != nil || len(instances) == 0 { + return gin.H{"instances": []gin.H{}} + } + + result := make([]gin.H, 0, len(instances)) + for _, creds := range instances { + result = append(result, gin.H{ + "connected": true, + "instanceName": creds.InstanceName, + "url": creds.URL, + "authMethod": creds.AuthMethod, + "updatedAt": creds.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + }) + } + + return gin.H{"instances": result} +} + +// getGitLabStatusForUser returns the GitLab integration status for a user. func getGitLabStatusForUser(ctx context.Context, userID string) gin.H { creds, err := GetGitLabCredentials(ctx, userID) if err != nil || creds == nil { diff --git a/components/backend/handlers/runtime_credentials.go b/components/backend/handlers/runtime_credentials.go old mode 100755 new mode 100644 index 6cf4ea460..6c0298752 --- a/components/backend/handlers/runtime_credentials.go +++ b/components/backend/handlers/runtime_credentials.go @@ -481,6 +481,55 @@ func fetchGitHubUserIdentity(ctx context.Context, token string) (userName, email return userName, email } +// GetGerritCredentialsForSession handles GET /api/projects/:project/agentic-sessions/:session/credentials/gerrit +// Returns all Gerrit instances' credentials for the session's user +func GetGerritCredentialsForSession(c *gin.Context) { + project := c.Param("projectName") + session := c.Param("sessionName") + + reqK8s, reqDyn := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + return + } + + effectiveUserID, ok := enforceCredentialRBAC(c, reqK8s, reqDyn, project, session) + if !ok { + return + } + + // Get all Gerrit instances for the user + instances, err := listGerritCredentials(c.Request.Context(), effectiveUserID) + if err != nil { + log.Printf("Failed to get Gerrit credentials for user %s: %v", effectiveUserID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get Gerrit credentials"}) + return + } + + if len(instances) == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Gerrit credentials not configured"}) + return + } + + result := make([]gin.H, 0, len(instances)) + for _, creds := range instances { + instance := gin.H{ + "instanceName": creds.InstanceName, + "url": creds.URL, + "authMethod": creds.AuthMethod, + } + if creds.AuthMethod == "http_basic" { + instance["username"] = creds.Username + instance["httpToken"] = creds.HTTPToken + } else if creds.AuthMethod == "git_cookies" { + instance["gitcookiesContent"] = creds.GitcookiesContent + } + result = append(result, instance) + } + + c.JSON(http.StatusOK, gin.H{"instances": result}) +} + // fetchGitLabUserIdentity fetches user name and email from GitLab API // Returns the user's name and email for git config func fetchGitLabUserIdentity(ctx context.Context, token, instanceURL string) (userName, email string) { diff --git a/components/backend/routes.go b/components/backend/routes.go index 0cd298ea9..6b2daeb12 100644 --- a/components/backend/routes.go +++ b/components/backend/routes.go @@ -83,6 +83,7 @@ func registerRoutes(r *gin.Engine) { projectGroup.GET("/agentic-sessions/:sessionName/credentials/google", handlers.GetGoogleCredentialsForSession) projectGroup.GET("/agentic-sessions/:sessionName/credentials/jira", handlers.GetJiraCredentialsForSession) projectGroup.GET("/agentic-sessions/:sessionName/credentials/gitlab", handlers.GetGitLabTokenForSession) + projectGroup.GET("/agentic-sessions/:sessionName/credentials/gerrit", handlers.GetGerritCredentialsForSession) projectGroup.GET("/agentic-sessions/:sessionName/credentials/mcp/:serverName", handlers.GetMCPCredentialsForSession) // Session export @@ -159,6 +160,13 @@ func registerRoutes(r *gin.Engine) { api.DELETE("/auth/gitlab/disconnect", handlers.DisconnectGitLabGlobal) api.POST("/auth/gitlab/test", handlers.TestGitLabConnection) + // Cluster-level Gerrit (user-scoped, multi-instance) + api.POST("/auth/gerrit/connect", handlers.ConnectGerrit) + api.POST("/auth/gerrit/test", handlers.TestGerritConnection) + api.GET("/auth/gerrit/instances", handlers.ListGerritInstances) + api.GET("/auth/gerrit/:instanceName/status", handlers.GetGerritStatus) + api.DELETE("/auth/gerrit/:instanceName/disconnect", handlers.DisconnectGerrit) + // Generic MCP server credentials (user-scoped) api.POST("/auth/mcp/:serverName/connect", handlers.ConnectMCPServer) api.GET("/auth/mcp/:serverName/status", handlers.GetMCPServerStatus) @@ -167,9 +175,6 @@ func registerRoutes(r *gin.Engine) { // Cluster info endpoint (public, no auth required) api.GET("/cluster-info", handlers.GetClusterInfo) - // Version endpoint (public, no auth required) - api.GET("/version", handlers.GetVersion) - // LDAP search endpoints (cluster-scoped, auth-required) api.GET("/ldap/users", handlers.SearchLDAPUsers) api.GET("/ldap/users/:uid", handlers.GetLDAPUser) diff --git a/components/frontend/src/app/api/auth/gerrit/[instanceName]/disconnect/route.ts b/components/frontend/src/app/api/auth/gerrit/[instanceName]/disconnect/route.ts new file mode 100644 index 000000000..687b643a5 --- /dev/null +++ b/components/frontend/src/app/api/auth/gerrit/[instanceName]/disconnect/route.ts @@ -0,0 +1,18 @@ +import { BACKEND_URL } from '@/lib/config' +import { buildForwardHeadersAsync } from '@/lib/auth' + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ instanceName: string }> } +) { + const { instanceName } = await params + const headers = await buildForwardHeadersAsync(request) + + const resp = await fetch(`${BACKEND_URL}/auth/gerrit/${instanceName}/disconnect`, { + method: 'DELETE', + headers, + }) + + const data = await resp.text() + return new Response(data, { status: resp.status, headers: { 'Content-Type': 'application/json' } }) +} diff --git a/components/frontend/src/app/api/auth/gerrit/[instanceName]/status/route.ts b/components/frontend/src/app/api/auth/gerrit/[instanceName]/status/route.ts new file mode 100644 index 000000000..9cf1ca129 --- /dev/null +++ b/components/frontend/src/app/api/auth/gerrit/[instanceName]/status/route.ts @@ -0,0 +1,18 @@ +import { BACKEND_URL } from '@/lib/config' +import { buildForwardHeadersAsync } from '@/lib/auth' + +export async function GET( + request: Request, + { params }: { params: Promise<{ instanceName: string }> } +) { + const { instanceName } = await params + const headers = await buildForwardHeadersAsync(request) + + const resp = await fetch(`${BACKEND_URL}/auth/gerrit/${instanceName}/status`, { + method: 'GET', + headers, + }) + + const data = await resp.text() + return new Response(data, { status: resp.status, headers: { 'Content-Type': 'application/json' } }) +} diff --git a/components/frontend/src/app/api/auth/gerrit/connect/route.ts b/components/frontend/src/app/api/auth/gerrit/connect/route.ts new file mode 100644 index 000000000..83b8957d7 --- /dev/null +++ b/components/frontend/src/app/api/auth/gerrit/connect/route.ts @@ -0,0 +1,16 @@ +import { BACKEND_URL } from '@/lib/config' +import { buildForwardHeadersAsync } from '@/lib/auth' + +export async function POST(request: Request) { + const headers = await buildForwardHeadersAsync(request) + const body = await request.text() + + const resp = await fetch(`${BACKEND_URL}/auth/gerrit/connect`, { + method: 'POST', + headers, + body, + }) + + const data = await resp.text() + return new Response(data, { status: resp.status, headers: { 'Content-Type': 'application/json' } }) +} diff --git a/components/frontend/src/app/api/auth/gerrit/instances/route.ts b/components/frontend/src/app/api/auth/gerrit/instances/route.ts new file mode 100644 index 000000000..928db061d --- /dev/null +++ b/components/frontend/src/app/api/auth/gerrit/instances/route.ts @@ -0,0 +1,14 @@ +import { BACKEND_URL } from '@/lib/config' +import { buildForwardHeadersAsync } from '@/lib/auth' + +export async function GET(request: Request) { + const headers = await buildForwardHeadersAsync(request) + + const resp = await fetch(`${BACKEND_URL}/auth/gerrit/instances`, { + method: 'GET', + headers, + }) + + const data = await resp.text() + return new Response(data, { status: resp.status, headers: { 'Content-Type': 'application/json' } }) +} diff --git a/components/frontend/src/app/api/auth/gerrit/test/route.ts b/components/frontend/src/app/api/auth/gerrit/test/route.ts new file mode 100644 index 000000000..b85426a02 --- /dev/null +++ b/components/frontend/src/app/api/auth/gerrit/test/route.ts @@ -0,0 +1,16 @@ +import { BACKEND_URL } from '@/lib/config' +import { buildForwardHeadersAsync } from '@/lib/auth' + +export async function POST(request: Request) { + const headers = await buildForwardHeadersAsync(request) + const body = await request.text() + + const resp = await fetch(`${BACKEND_URL}/auth/gerrit/test`, { + method: 'POST', + headers, + body, + }) + + const data = await resp.text() + return new Response(data, { status: resp.status, headers: { 'Content-Type': 'application/json' } }) +} diff --git a/components/frontend/src/app/integrations/IntegrationsClient.tsx b/components/frontend/src/app/integrations/IntegrationsClient.tsx index 47ffc0602..18db9053e 100644 --- a/components/frontend/src/app/integrations/IntegrationsClient.tsx +++ b/components/frontend/src/app/integrations/IntegrationsClient.tsx @@ -4,6 +4,7 @@ import { GitHubConnectionCard } from '@/components/github-connection-card' import { GoogleDriveConnectionCard } from '@/components/google-drive-connection-card' import { GitLabConnectionCard } from '@/components/gitlab-connection-card' import { JiraConnectionCard } from '@/components/jira-connection-card' +import { GerritConnectionCard } from '@/components/gerrit-connection-card' import { PageHeader } from '@/components/page-header' import { useIntegrationsStatus } from '@/services/queries/use-integrations' import { Loader2 } from 'lucide-react' @@ -53,6 +54,10 @@ export default function IntegrationsClient({ appSlug }: Props) { status={integrations?.jira} onRefresh={refetch} /> + )} diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/settings/integrations-panel.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/settings/integrations-panel.tsx index 0ce6ae906..1645620e7 100644 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/settings/integrations-panel.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/settings/integrations-panel.tsx @@ -19,6 +19,8 @@ export function IntegrationsPanel() { const gitlabConfigured = integrationsStatus?.gitlab?.connected ?? false; const jiraConfigured = integrationsStatus?.jira?.connected ?? false; const googleConfigured = integrationsStatus?.google?.connected ?? false; + const gerritConfigured = (integrationsStatus?.gerrit?.instances?.length ?? 0) > 0; + const gerritUrls = integrationsStatus?.gerrit?.instances?.map((i) => i.url).join(", "); const integrations = [ { @@ -48,6 +50,14 @@ export function IntegrationsPanel() { configured: jiraConfigured, configuredMessage: "Authenticated. Issue and project access enabled.", }, + { + key: "gerrit", + name: "Gerrit", + configured: gerritConfigured, + configuredMessage: gerritConfigured + ? `Connected to ${gerritUrls}. Code review access enabled.` + : "", + }, ].sort((a, b) => a.name.localeCompare(b.name)); const configuredCount = integrations.filter((i) => i.configured).length; diff --git a/components/frontend/src/components/gerrit-connection-card.tsx b/components/frontend/src/components/gerrit-connection-card.tsx new file mode 100644 index 000000000..bb7c325ef --- /dev/null +++ b/components/frontend/src/components/gerrit-connection-card.tsx @@ -0,0 +1,341 @@ +'use client' + +import React, { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Loader2, Eye, EyeOff } from 'lucide-react' +import { toast } from 'sonner' +import { useConnectGerrit, useDisconnectGerrit, useTestGerritConnection } from '@/services/queries/use-gerrit' +import type { GerritAuthMethod } from '@/services/api/gerrit-auth' + +type GerritInstanceInfo = { + connected: boolean + instanceName: string + url: string + authMethod: 'http_basic' | 'git_cookies' + updatedAt: string +} + +type Props = { + status?: { + instances: GerritInstanceInfo[] + } + onRefresh?: () => void +} + +/** Card component for managing Gerrit instance connections with multi-instance support. */ +export function GerritConnectionCard({ status, onRefresh }: Props) { + const connectMutation = useConnectGerrit() + const disconnectMutation = useDisconnectGerrit() + const testMutation = useTestGerritConnection() + const isLoading = !status + + const [showForm, setShowForm] = useState(false) + const [instanceName, setInstanceName] = useState('') + const [url, setUrl] = useState('') + const [authMethod, setAuthMethod] = useState('http_basic') + const [username, setUsername] = useState('') + const [httpToken, setHttpToken] = useState('') + const [gitcookiesContent, setGitcookiesContent] = useState('') + const [showToken, setShowToken] = useState(false) + + const instances = status?.instances ?? [] + const hasInstances = instances.length > 0 + + const resetForm = () => { + setInstanceName('') + setUrl('') + setAuthMethod('http_basic') + setUsername('') + setHttpToken('') + setGitcookiesContent('') + setShowToken(false) + setShowForm(false) + } + + const buildRequest = () => ({ + instanceName, + url, + authMethod, + ...(authMethod === 'http_basic' ? { username, httpToken } : { gitcookiesContent }), + }) + + const isFormValid = () => { + if (!instanceName || instanceName.length < 2 || !url) return false + if (authMethod === 'http_basic') return !!username && !!httpToken + return !!gitcookiesContent + } + + const handleTest = () => { + testMutation.mutate( + { url, authMethod, ...(authMethod === 'http_basic' ? { username, httpToken } : { gitcookiesContent }) }, + { + onSuccess: (result) => { + if (result.valid) { + toast.success('Connection test successful') + } else { + toast.error(result.error || 'Connection test failed') + } + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : 'Connection test failed') + }, + } + ) + } + + const handleConnect = () => { + connectMutation.mutate(buildRequest(), { + onSuccess: () => { + toast.success(`Gerrit instance '${instanceName}' connected successfully`) + resetForm() + onRefresh?.() + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : 'Failed to connect Gerrit') + }, + }) + } + + const handleDisconnect = (name: string) => { + disconnectMutation.mutate(name, { + onSuccess: () => { + toast.success(`Gerrit instance '${name}' disconnected`) + onRefresh?.() + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : 'Failed to disconnect') + }, + }) + } + + return ( + +
+ {/* Header */} +
+
+ +
+
+

Gerrit

+

Connect to Gerrit for code review

+
+
+ + {/* Connected instances list */} + {hasInstances && !showForm && ( +
+ {instances.map((inst) => ( +
+
+ +
+

{inst.instanceName}

+

{inst.url}

+
+
+ +
+ ))} +
+ )} + + {/* Status when no instances */} + {!hasInstances && !showForm && ( +
+
+ + Not Connected +
+

+ Connect to Gerrit to review changes, submit comments, and manage code reviews across all sessions +

+
+ )} + + {/* Connection form */} + {showForm && ( +
+
+ + setInstanceName(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))} + disabled={connectMutation.isPending} + className="mt-1" + minLength={2} + maxLength={63} + /> +

+ A short name to identify this Gerrit instance (2-63 chars, lowercase, hyphens allowed) +

+
+
+ + setUrl(e.target.value)} + disabled={connectMutation.isPending} + className="mt-1" + /> +
+
+ +
+ + +
+
+ + {authMethod === 'http_basic' ? ( + <> +
+ + setUsername(e.target.value)} + disabled={connectMutation.isPending} + className="mt-1" + /> +
+
+ +
+ setHttpToken(e.target.value)} + disabled={connectMutation.isPending} + /> + +
+

+ Generate an HTTP password in Gerrit Settings > HTTP Credentials +

+
+ + ) : ( +
+ +