diff --git a/.gitignore b/.gitignore index 72cbbbec7..a078165f2 100644 --- a/.gitignore +++ b/.gitignore @@ -140,3 +140,4 @@ hack/ # Personal exports *.csv +.worktrees/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f53170109..d50e80ea8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -63,6 +63,17 @@ repos: files: ^components/frontend/.*\.(ts|tsx|js|jsx)$ pass_filenames: true + # ── CodeRabbit AI review ─────────────────────────────────────────── + - repo: local + hooks: + - id: coderabbit-review + name: coderabbit review + entry: scripts/pre-commit/coderabbit-review.sh + language: script + always_run: true + pass_filenames: false + require_serial: true + # ── Branch protection ──────────────────────────────────────────────── - repo: local hooks: diff --git a/components/backend/handlers/coderabbit_auth.go b/components/backend/handlers/coderabbit_auth.go new file mode 100644 index 000000000..c25e89e91 --- /dev/null +++ b/components/backend/handlers/coderabbit_auth.go @@ -0,0 +1,260 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "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" +) + +// CodeRabbitCredentials represents cluster-level CodeRabbit credentials for a user +type CodeRabbitCredentials struct { + UserID string `json:"userId"` + APIKey string `json:"apiKey"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// ConnectCodeRabbit handles POST /api/auth/coderabbit/connect +// Saves user's CodeRabbit credentials at cluster level +func ConnectCodeRabbit(c *gin.Context) { + // Verify user has valid K8s token (follows RBAC pattern) + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + return + } + + // Verify user is authenticated and userID is valid + 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 { + APIKey string `json:"apiKey" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Validate API key with CodeRabbit + if err := ValidateCodeRabbitAPIKey(c.Request.Context(), req.APIKey); err != nil { + log.Printf("Failed to validate CodeRabbit API key for user %s: %v", userID, err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid CodeRabbit API key"}) + return + } + + // Store credentials + creds := &CodeRabbitCredentials{ + UserID: userID, + APIKey: req.APIKey, + UpdatedAt: time.Now(), + } + + if err := storeCodeRabbitCredentials(c.Request.Context(), creds); err != nil { + log.Printf("Failed to store CodeRabbit credentials for user %s: %v", userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save CodeRabbit credentials"}) + return + } + + log.Printf("✓ Stored CodeRabbit credentials for user %s", userID) + c.JSON(http.StatusOK, gin.H{ + "message": "CodeRabbit connected successfully", + }) +} + +// GetCodeRabbitStatus handles GET /api/auth/coderabbit/status +// Returns connection status for the authenticated user +func GetCodeRabbitStatus(c *gin.Context) { + // Verify user has valid K8s token + 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 + } + + creds, err := GetCodeRabbitCredentials(c.Request.Context(), userID) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusOK, gin.H{"connected": false}) + return + } + log.Printf("Failed to get CodeRabbit credentials for user %s: %v", userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check CodeRabbit status"}) + return + } + + if creds == nil { + c.JSON(http.StatusOK, gin.H{"connected": false}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "connected": true, + "updatedAt": creds.UpdatedAt.Format(time.RFC3339), + }) +} + +// DisconnectCodeRabbit handles DELETE /api/auth/coderabbit/disconnect +// Removes user's CodeRabbit credentials +func DisconnectCodeRabbit(c *gin.Context) { + // Verify user has valid K8s token + 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 err := DeleteCodeRabbitCredentials(c.Request.Context(), userID); err != nil { + log.Printf("Failed to delete CodeRabbit credentials for user %s: %v", userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to disconnect CodeRabbit"}) + return + } + + log.Printf("✓ Deleted CodeRabbit credentials for user %s", userID) + c.JSON(http.StatusOK, gin.H{"message": "CodeRabbit disconnected successfully"}) +} + +// storeCodeRabbitCredentials stores CodeRabbit credentials in cluster-level Secret +func storeCodeRabbitCredentials(ctx context.Context, creds *CodeRabbitCredentials) error { + if creds == nil || creds.UserID == "" { + return fmt.Errorf("invalid credentials payload") + } + + const secretName = "coderabbit-credentials" + + for i := 0; i < 3; i++ { // retry on conflict + secret, err := K8sClient.CoreV1().Secrets(Namespace).Get(ctx, secretName, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + // Create Secret + secret = &corev1.Secret{ + ObjectMeta: v1.ObjectMeta{ + Name: secretName, + Namespace: Namespace, + Labels: map[string]string{ + "app": "ambient-code", + "ambient-code.io/provider": "coderabbit", + }, + }, + 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) + } + // Fetch again to get resourceVersion + secret, err = K8sClient.CoreV1().Secrets(Namespace).Get(ctx, secretName, 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[creds.UserID] = b + + if _, uerr := K8sClient.CoreV1().Secrets(Namespace).Update(ctx, secret, v1.UpdateOptions{}); uerr != nil { + if errors.IsConflict(uerr) { + continue // retry + } + return fmt.Errorf("failed to update Secret: %w", uerr) + } + return nil + } + return fmt.Errorf("failed to update Secret after retries") +} + +// GetCodeRabbitCredentials retrieves cluster-level CodeRabbit credentials for a user +func GetCodeRabbitCredentials(ctx context.Context, userID string) (*CodeRabbitCredentials, error) { + if userID == "" { + return nil, fmt.Errorf("userID is required") + } + + const secretName = "coderabbit-credentials" + + secret, err := K8sClient.CoreV1().Secrets(Namespace).Get(ctx, secretName, v1.GetOptions{}) + if err != nil { + return nil, err + } + + if secret.Data == nil || len(secret.Data[userID]) == 0 { + return nil, nil // User hasn't connected CodeRabbit + } + + var creds CodeRabbitCredentials + if err := json.Unmarshal(secret.Data[userID], &creds); err != nil { + return nil, fmt.Errorf("failed to parse credentials: %w", err) + } + + return &creds, nil +} + +// DeleteCodeRabbitCredentials removes CodeRabbit credentials for a user +func DeleteCodeRabbitCredentials(ctx context.Context, userID string) error { + if userID == "" { + return fmt.Errorf("userID is required") + } + + const secretName = "coderabbit-credentials" + + for i := 0; i < 3; i++ { // retry on conflict + secret, err := K8sClient.CoreV1().Secrets(Namespace).Get(ctx, secretName, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return nil // Secret doesn't exist, nothing to delete + } + return fmt.Errorf("failed to get Secret: %w", err) + } + + if secret.Data == nil || len(secret.Data[userID]) == 0 { + return nil // User's credentials don't exist + } + + delete(secret.Data, userID) + + if _, uerr := K8sClient.CoreV1().Secrets(Namespace).Update(ctx, secret, v1.UpdateOptions{}); uerr != nil { + if errors.IsConflict(uerr) { + continue // retry + } + 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/coderabbit_auth_test.go b/components/backend/handlers/coderabbit_auth_test.go new file mode 100644 index 000000000..073d5d4ba --- /dev/null +++ b/components/backend/handlers/coderabbit_auth_test.go @@ -0,0 +1,369 @@ +//go:build test + +package handlers + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + + "ambient-code-backend/tests/config" + test_constants "ambient-code-backend/tests/constants" + "ambient-code-backend/tests/logger" + "ambient-code-backend/tests/test_utils" + + . "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("CodeRabbit Auth Handlers", Label(test_constants.LabelUnit, test_constants.LabelHandlers, test_constants.LabelCodeRabbitAuth), func() { + var ( + httpUtils *test_utils.HTTPTestUtils + k8sUtils *test_utils.K8sTestUtils + namespace string + ) + + BeforeEach(func() { + logger.Log("Setting up CodeRabbit Auth Handler test") + + httpUtils = test_utils.NewHTTPTestUtils() + k8sUtils = test_utils.NewK8sTestUtils(false, *config.TestNamespace) + namespace = *config.TestNamespace + + // Set up handler dependencies + SetupHandlerDependencies(k8sUtils) + Namespace = namespace + + // Create namespace + ctx := context.Background() + _, err := k8sUtils.K8sClient.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: namespace}, + }, metav1.CreateOptions{}) + if err != nil && !errors.IsAlreadyExists(err) { + Expect(err).NotTo(HaveOccurred(), fmt.Sprintf("Failed to create namespace %s", namespace)) + } + }) + + AfterEach(func() { + // Cleanup + if k8sUtils == nil { + return + } + ctx := context.Background() + _ = k8sUtils.K8sClient.CoreV1().Namespaces().Delete(ctx, namespace, metav1.DeleteOptions{}) + }) + + Describe("ConnectCodeRabbit", func() { + var ( + mockServer *httptest.Server + originalValidation func(context.Context, string) error + ) + + BeforeEach(func() { + // Mock the CodeRabbit health endpoint + mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/health" { + auth := r.Header.Get("Authorization") + if auth == "Bearer valid-api-key" { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusNotFound) + })) + + // Save original validation function + originalValidation = ValidateCodeRabbitAPIKey + + // Replace with mock that uses our test server + ValidateCodeRabbitAPIKey = func(ctx context.Context, apiKey string) error { + if apiKey == "" { + return fmt.Errorf("API key is empty") + } + if apiKey == "valid-api-key" { + return nil + } + return fmt.Errorf("invalid API key") + } + }) + + AfterEach(func() { + if mockServer != nil { + mockServer.Close() + } + // Restore original validation + ValidateCodeRabbitAPIKey = originalValidation + }) + + Context("When storing credentials with valid request", func() { + It("Should store credentials successfully", func() { + reqBody := map[string]string{ + "apiKey": "valid-api-key", + } + + c := httpUtils.CreateTestGinContext("POST", "/api/auth/coderabbit/connect", reqBody) + httpUtils.SetAuthHeader("valid-test-token") + c.Set("userID", "test-user") + + ConnectCodeRabbit(c) + + httpUtils.AssertHTTPStatus(http.StatusOK) + + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response).To(HaveKey("message")) + Expect(response["message"]).To(Equal("CodeRabbit connected successfully")) + + // Verify credentials were stored + ctx := context.Background() + creds, err := GetCodeRabbitCredentials(ctx, "test-user") + Expect(err).NotTo(HaveOccurred()) + Expect(creds).NotTo(BeNil()) + Expect(creds.UserID).To(Equal("test-user")) + Expect(creds.APIKey).To(Equal("valid-api-key")) + + logger.Log("Successfully stored CodeRabbit credentials") + }) + }) + + Context("When validating request input", func() { + It("Should reject request with empty API key", func() { + reqBody := map[string]string{ + "apiKey": "", + } + + c := httpUtils.CreateTestGinContext("POST", "/api/auth/coderabbit/connect", reqBody) + httpUtils.SetAuthHeader("valid-test-token") + c.Set("userID", "test-user") + + ConnectCodeRabbit(c) + + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response).To(HaveKey("error")) + + logger.Log("Correctly rejected empty API key") + }) + + It("Should reject request with invalid API key", func() { + reqBody := map[string]string{ + "apiKey": "invalid-api-key", + } + + c := httpUtils.CreateTestGinContext("POST", "/api/auth/coderabbit/connect", reqBody) + httpUtils.SetAuthHeader("valid-test-token") + c.Set("userID", "test-user") + + ConnectCodeRabbit(c) + + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response).To(HaveKey("error")) + Expect(response["error"]).To(Equal("Invalid CodeRabbit API key")) + + logger.Log("Correctly rejected invalid API key") + }) + }) + + Context("When handling authentication", func() { + It("Should reject unauthenticated requests", func() { + reqBody := map[string]string{ + "apiKey": "valid-api-key", + } + + c := httpUtils.CreateTestGinContext("POST", "/api/auth/coderabbit/connect", reqBody) + // No auth header set + + ConnectCodeRabbit(c) + + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response).To(HaveKey("error")) + Expect(response["error"]).To(Equal("Invalid or missing token")) + + logger.Log("Correctly rejected unauthenticated request") + }) + + It("Should reject request without userID", func() { + reqBody := map[string]string{ + "apiKey": "valid-api-key", + } + + c := httpUtils.CreateTestGinContext("POST", "/api/auth/coderabbit/connect", reqBody) + // Set auth header manually without SetAuthHeader (which auto-sets userID) + c.Request.Header.Set("Authorization", "Bearer valid-test-token") + // Do NOT set userID in context + + ConnectCodeRabbit(c) + + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response).To(HaveKey("error")) + Expect(response["error"]).To(Equal("User authentication required")) + + logger.Log("Correctly rejected request without userID") + }) + }) + }) + + Describe("GetCodeRabbitStatus", func() { + Context("When credentials exist", func() { + It("Should return connected status with timestamp", func() { + // First store credentials + ctx := context.Background() + creds := &CodeRabbitCredentials{ + UserID: "test-user", + APIKey: "test-api-key", + } + err := storeCodeRabbitCredentials(ctx, creds) + Expect(err).NotTo(HaveOccurred()) + + // Now check status + c := httpUtils.CreateTestGinContext("GET", "/api/auth/coderabbit/status", nil) + httpUtils.SetAuthHeader("valid-test-token") + c.Set("userID", "test-user") + + GetCodeRabbitStatus(c) + + httpUtils.AssertHTTPStatus(http.StatusOK) + + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response).To(HaveKey("connected")) + Expect(response["connected"]).To(BeTrue()) + Expect(response).To(HaveKey("updatedAt")) + + logger.Log("Successfully retrieved connected status") + }) + }) + + Context("When credentials do not exist", func() { + It("Should return disconnected status", func() { + c := httpUtils.CreateTestGinContext("GET", "/api/auth/coderabbit/status", nil) + httpUtils.SetAuthHeader("valid-test-token") + c.Set("userID", "nonexistent-user") + + GetCodeRabbitStatus(c) + + httpUtils.AssertHTTPStatus(http.StatusOK) + + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response).To(HaveKey("connected")) + Expect(response["connected"]).To(BeFalse()) + + logger.Log("Successfully returned disconnected status for user without credentials") + }) + }) + + Context("When handling authentication", func() { + It("Should reject unauthenticated requests", func() { + c := httpUtils.CreateTestGinContext("GET", "/api/auth/coderabbit/status", nil) + // No auth header + + GetCodeRabbitStatus(c) + + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response).To(HaveKey("error")) + Expect(response["error"]).To(Equal("Invalid or missing token")) + + logger.Log("Correctly rejected unauthenticated request") + }) + }) + }) + + Describe("DisconnectCodeRabbit", func() { + Context("When removing existing credentials", func() { + It("Should delete credentials successfully", func() { + // First store credentials + ctx := context.Background() + creds := &CodeRabbitCredentials{ + UserID: "test-user", + APIKey: "test-api-key", + } + err := storeCodeRabbitCredentials(ctx, creds) + Expect(err).NotTo(HaveOccurred()) + + // Verify they exist + retrievedCreds, err := GetCodeRabbitCredentials(ctx, "test-user") + Expect(err).NotTo(HaveOccurred()) + Expect(retrievedCreds).NotTo(BeNil()) + + // Now disconnect + c := httpUtils.CreateTestGinContext("DELETE", "/api/auth/coderabbit/disconnect", nil) + httpUtils.SetAuthHeader("valid-test-token") + c.Set("userID", "test-user") + + DisconnectCodeRabbit(c) + + httpUtils.AssertHTTPStatus(http.StatusOK) + + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response).To(HaveKey("message")) + Expect(response["message"]).To(Equal("CodeRabbit disconnected successfully")) + + // Verify credentials were deleted + deletedCreds, err := GetCodeRabbitCredentials(ctx, "test-user") + Expect(err).NotTo(HaveOccurred()) + Expect(deletedCreds).To(BeNil()) + + logger.Log("Successfully deleted CodeRabbit credentials") + }) + }) + + Context("When credentials do not exist", func() { + It("Should succeed idempotently", func() { + c := httpUtils.CreateTestGinContext("DELETE", "/api/auth/coderabbit/disconnect", nil) + httpUtils.SetAuthHeader("valid-test-token") + c.Set("userID", "nonexistent-user") + + DisconnectCodeRabbit(c) + + httpUtils.AssertHTTPStatus(http.StatusOK) + + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response).To(HaveKey("message")) + Expect(response["message"]).To(Equal("CodeRabbit disconnected successfully")) + + logger.Log("Successfully handled disconnect for nonexistent credentials") + }) + }) + + Context("When handling authentication", func() { + It("Should reject unauthenticated requests", func() { + c := httpUtils.CreateTestGinContext("DELETE", "/api/auth/coderabbit/disconnect", nil) + // No auth header + + DisconnectCodeRabbit(c) + + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response).To(HaveKey("error")) + Expect(response["error"]).To(Equal("Invalid or missing token")) + + logger.Log("Correctly rejected unauthenticated request") + }) + }) + }) +}) diff --git a/components/backend/handlers/integration_validation.go b/components/backend/handlers/integration_validation.go index d66d406f9..0279e5716 100644 --- a/components/backend/handlers/integration_validation.go +++ b/components/backend/handlers/integration_validation.go @@ -219,3 +219,53 @@ func TestGitLabConnection(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"valid": true, "message": "GitLab connection successful"}) } + +// validateCodeRabbitAPIKeyImpl is the default implementation +func validateCodeRabbitAPIKeyImpl(ctx context.Context, apiKey string) error { + if apiKey == "" { + return fmt.Errorf("API key is empty") + } + + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequestWithContext(ctx, "GET", "https://api.coderabbit.ai/api/v1/health", nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("request failed") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("invalid API key") + } + + return nil +} + +// ValidateCodeRabbitAPIKey is mockable for testing +var ValidateCodeRabbitAPIKey = validateCodeRabbitAPIKeyImpl + +// TestCodeRabbitConnection handles POST /api/auth/coderabbit/test +func TestCodeRabbitConnection(c *gin.Context) { + var req struct { + APIKey string `json:"apiKey" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + err := ValidateCodeRabbitAPIKey(c.Request.Context(), req.APIKey) + if err != nil { + c.JSON(http.StatusOK, gin.H{"valid": false, "error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"valid": true, "message": "CodeRabbit connection successful"}) +} diff --git a/components/backend/handlers/integrations_status.go b/components/backend/handlers/integrations_status.go index 36e992c59..df2372bdd 100644 --- a/components/backend/handlers/integrations_status.go +++ b/components/backend/handlers/integrations_status.go @@ -42,6 +42,9 @@ func GetIntegrationsStatus(c *gin.Context) { // MCP server credentials status response["mcpServers"] = getMCPServerStatusForUser(ctx, userID) + // CodeRabbit status + response["coderabbit"] = getCodeRabbitStatusForUser(ctx, userID) + c.JSON(http.StatusOK, response) } @@ -144,3 +147,16 @@ func getGitLabStatusForUser(ctx context.Context, userID string) gin.H { "valid": true, } } + +func getCodeRabbitStatusForUser(ctx context.Context, userID string) gin.H { + creds, err := GetCodeRabbitCredentials(ctx, userID) + if err != nil || creds == nil { + return gin.H{"connected": false} + } + + return gin.H{ + "connected": true, + "updatedAt": creds.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + "valid": true, + } +} diff --git a/components/backend/handlers/runtime_credentials.go b/components/backend/handlers/runtime_credentials.go index 6cf4ea460..534d52328 100755 --- a/components/backend/handlers/runtime_credentials.go +++ b/components/backend/handlers/runtime_credentials.go @@ -547,3 +547,62 @@ func fetchGitLabUserIdentity(ctx context.Context, token, instanceURL string) (us log.Printf("Fetched GitLab user identity: name=%q hasEmail=%t", userName, email != "") return userName, email } + +// GetCodeRabbitCredentialsForSession handles GET /api/projects/:project/agentic-sessions/:session/credentials/coderabbit +func GetCodeRabbitCredentialsForSession(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 + } + + gvr := GetAgenticSessionV1Alpha1Resource() + obj, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), session, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) + return + } + log.Printf("Failed to get session %s/%s: %v", project, session, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get session"}) + return + } + + userID, found, err := unstructured.NestedString(obj.Object, "spec", "userContext", "userId") + if !found || err != nil || userID == "" { + log.Printf("Failed to extract userID from session %s/%s: found=%v, err=%v", project, session, found, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "User ID not found in session"}) + return + } + + // Verify authenticated user owns this session (RBAC: prevent accessing other users' credentials) + // Note: BOT_TOKEN (session ServiceAccount) won't have userID in context, which is fine - + // BOT_TOKEN is already scoped to this specific session via RBAC + authenticatedUserID := c.GetString("userID") + if authenticatedUserID != "" && authenticatedUserID != userID { + log.Printf("RBAC violation: user %s attempted to access credentials for session owned by %s", authenticatedUserID, userID) + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied: session belongs to different user"}) + return + } + // If authenticatedUserID is empty, this is likely BOT_TOKEN (session-scoped ServiceAccount) + // which is allowed because it's already restricted to this session via K8s RBAC + + creds, err := GetCodeRabbitCredentials(c.Request.Context(), userID) + if err != nil { + log.Printf("Failed to get CodeRabbit credentials for user %s: %v", userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get CodeRabbit credentials"}) + return + } + + if creds == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "CodeRabbit credentials not configured"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "apiKey": creds.APIKey, + }) +} diff --git a/components/backend/routes.go b/components/backend/routes.go index 4d5bac93b..658f27557 100644 --- a/components/backend/routes.go +++ b/components/backend/routes.go @@ -89,6 +89,7 @@ func registerRoutes(r *gin.Engine) { projectGroup.GET("/agentic-sessions/:sessionName/credentials/jira", handlers.GetJiraCredentialsForSession) projectGroup.GET("/agentic-sessions/:sessionName/credentials/gitlab", handlers.GetGitLabTokenForSession) projectGroup.GET("/agentic-sessions/:sessionName/credentials/mcp/:serverName", handlers.GetMCPCredentialsForSession) + projectGroup.GET("/agentic-sessions/:sessionName/credentials/coderabbit", handlers.GetCodeRabbitCredentialsForSession) // Session export projectGroup.GET("/agentic-sessions/:sessionName/export", websocket.HandleExportSession) @@ -164,6 +165,12 @@ func registerRoutes(r *gin.Engine) { api.DELETE("/auth/gitlab/disconnect", handlers.DisconnectGitLabGlobal) api.POST("/auth/gitlab/test", handlers.TestGitLabConnection) + // Cluster-level CodeRabbit (user-scoped) + api.POST("/auth/coderabbit/connect", handlers.ConnectCodeRabbit) + api.GET("/auth/coderabbit/status", handlers.GetCodeRabbitStatus) + api.DELETE("/auth/coderabbit/disconnect", handlers.DisconnectCodeRabbit) + api.POST("/auth/coderabbit/test", handlers.TestCodeRabbitConnection) + // Generic MCP server credentials (user-scoped) api.POST("/auth/mcp/:serverName/connect", handlers.ConnectMCPServer) api.GET("/auth/mcp/:serverName/status", handlers.GetMCPServerStatus) diff --git a/components/backend/tests/constants/labels.go b/components/backend/tests/constants/labels.go index bdc7a5d57..be6971815 100644 --- a/components/backend/tests/constants/labels.go +++ b/components/backend/tests/constants/labels.go @@ -12,20 +12,21 @@ 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" - LabelFeatureFlags = "feature-flags" - 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" + LabelCodeRabbitAuth = "coderabbit-auth" + LabelSessions = "sessions" + LabelContent = "content" + LabelFeatureFlags = "feature-flags" + LabelDisplayName = "display-name" + LabelHealth = "health" // Specific component labels for other areas LabelOperations = "operations" // for git operations diff --git a/components/frontend/src/app/api/auth/coderabbit/connect/route.ts b/components/frontend/src/app/api/auth/coderabbit/connect/route.ts new file mode 100644 index 000000000..26081eda5 --- /dev/null +++ b/components/frontend/src/app/api/auth/coderabbit/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/coderabbit/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/coderabbit/disconnect/route.ts b/components/frontend/src/app/api/auth/coderabbit/disconnect/route.ts new file mode 100644 index 000000000..61e09cd63 --- /dev/null +++ b/components/frontend/src/app/api/auth/coderabbit/disconnect/route.ts @@ -0,0 +1,14 @@ +import { BACKEND_URL } from '@/lib/config' +import { buildForwardHeadersAsync } from '@/lib/auth' + +export async function DELETE(request: Request) { + const headers = await buildForwardHeadersAsync(request) + + const resp = await fetch(`${BACKEND_URL}/auth/coderabbit/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/coderabbit/status/route.ts b/components/frontend/src/app/api/auth/coderabbit/status/route.ts new file mode 100644 index 000000000..8a8747874 --- /dev/null +++ b/components/frontend/src/app/api/auth/coderabbit/status/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/coderabbit/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/coderabbit/test/route.ts b/components/frontend/src/app/api/auth/coderabbit/test/route.ts new file mode 100644 index 000000000..08698b673 --- /dev/null +++ b/components/frontend/src/app/api/auth/coderabbit/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/coderabbit/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..ca195b32f 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 { CodeRabbitConnectionCard } from '@/components/coderabbit-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/__tests__/integrations-panel.test.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/settings/__tests__/integrations-panel.test.tsx index 1b18e4798..2d1381579 100644 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/settings/__tests__/integrations-panel.test.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/settings/__tests__/integrations-panel.test.tsx @@ -7,6 +7,7 @@ type IntegrationsData = { gitlab: { connected: boolean }; jira: { connected: boolean }; google: { connected: boolean }; + coderabbit: { connected: boolean; updatedAt: string; valid: boolean }; } | null; const mockUseIntegrationsStatus = vi.fn((): { data: IntegrationsData; isPending: boolean } => ({ @@ -36,13 +37,14 @@ describe('IntegrationsPanel', () => { expect(skeletons.length).toBeGreaterThan(0); }); - it('renders integration cards (GitHub, GitLab, Google Workspace, Jira)', () => { + it('renders integration cards (GitHub, GitLab, Google Workspace, Jira, CodeRabbit)', () => { mockUseIntegrationsStatus.mockReturnValue({ data: { github: { active: null }, gitlab: { connected: false }, jira: { connected: false }, google: { connected: false }, + coderabbit: { connected: true, updatedAt: '2026-04-01T00:00:00Z', valid: true }, }, isPending: false, }); @@ -51,6 +53,7 @@ describe('IntegrationsPanel', () => { expect(screen.getByText('GitLab')).toBeDefined(); expect(screen.getByText('Google Workspace')).toBeDefined(); expect(screen.getByText('Jira')).toBeDefined(); + expect(screen.getByText('CodeRabbit')).toBeDefined(); }); it('shows connected status for configured integrations', () => { @@ -60,11 +63,12 @@ describe('IntegrationsPanel', () => { gitlab: { connected: true }, jira: { connected: true }, google: { connected: false }, + coderabbit: { connected: true, updatedAt: '2026-04-01T00:00:00Z', valid: true }, }, isPending: false, }); render(); - // 3 out of 4 configured: badge should show 3/4 - expect(screen.getByText('3/4')).toBeDefined(); + // 4 out of 5 configured: badge should show 4/5 + expect(screen.getByText('4/5')).toBeDefined(); }); }); 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..74d8621e9 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,7 @@ export function IntegrationsPanel() { const gitlabConfigured = integrationsStatus?.gitlab?.connected ?? false; const jiraConfigured = integrationsStatus?.jira?.connected ?? false; const googleConfigured = integrationsStatus?.google?.connected ?? false; + const coderabbitConfigured = integrationsStatus?.coderabbit?.connected ?? false; const integrations = [ { @@ -48,6 +49,12 @@ export function IntegrationsPanel() { configured: jiraConfigured, configuredMessage: "Authenticated. Issue and project access enabled.", }, + { + key: "coderabbit", + name: "CodeRabbit", + configured: coderabbitConfigured, + configuredMessage: "Authenticated. AI code review enabled in sessions.", + }, ].sort((a, b) => a.name.localeCompare(b.name)); const configuredCount = integrations.filter((i) => i.configured).length; @@ -132,4 +139,3 @@ function IntegrationCard({ ); } - diff --git a/components/frontend/src/components/coderabbit-connection-card.tsx b/components/frontend/src/components/coderabbit-connection-card.tsx new file mode 100644 index 000000000..e506b275e --- /dev/null +++ b/components/frontend/src/components/coderabbit-connection-card.tsx @@ -0,0 +1,208 @@ +'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 { useConnectCodeRabbit, useDisconnectCodeRabbit } from '@/services/queries/use-coderabbit' + +type Props = { + status?: { + connected: boolean + updatedAt?: string + valid?: boolean + } + onRefresh?: () => void +} + +export function CodeRabbitConnectionCard({ status, onRefresh }: Props) { + const connectMutation = useConnectCodeRabbit() + const disconnectMutation = useDisconnectCodeRabbit() + const isLoading = !status + + const [showForm, setShowForm] = useState(false) + const [apiKey, setApiKey] = useState('') + const [showApiKey, setShowApiKey] = useState(false) + + const handleConnect = async () => { + if (!apiKey) { + toast.error('Please enter an API key') + return + } + + connectMutation.mutate( + { apiKey }, + { + onSuccess: () => { + toast.success('CodeRabbit connected successfully') + setShowForm(false) + setApiKey('') + onRefresh?.() + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : 'Failed to connect CodeRabbit') + }, + } + ) + } + + const handleDisconnect = async () => { + disconnectMutation.mutate(undefined, { + onSuccess: () => { + toast.success('CodeRabbit disconnected successfully') + onRefresh?.() + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : 'Failed to disconnect CodeRabbit') + }, + }) + } + + const handleEdit = () => { + setShowForm(true) + } + + return ( + +
+ {/* Header section with icon and title */} +
+
+ +
+
+

CodeRabbit

+

Connect to CodeRabbit for AI-powered code review

+
+
+ + {/* Status section */} +
+
+ + + {status?.connected ? 'Connected' : 'Not Connected'} + +
+ {status?.connected && status.valid === false && ( +

+ ⚠️ API key appears invalid - click Edit to update +

+ )} + {status?.connected && status.updatedAt && ( +

+ Last updated: {new Date(status.updatedAt).toLocaleString()} +

+ )} +

+ Connect to CodeRabbit to enable AI-powered code review across all sessions +

+
+ + {/* Connection form */} + {showForm && ( +
+
+ +
+ setApiKey(e.target.value)} + disabled={connectMutation.isPending} + /> + +
+

+ Create an API key at{' '} + + CodeRabbit Settings + +

+
+
+ + +
+
+ )} + + {/* Action buttons */} +
+ {status?.connected && !showForm ? ( + <> + + + + ) : !showForm ? ( + + ) : null} +
+
+
+ ) +} diff --git a/components/frontend/src/services/api/coderabbit-auth.ts b/components/frontend/src/services/api/coderabbit-auth.ts new file mode 100644 index 000000000..97f1a3302 --- /dev/null +++ b/components/frontend/src/services/api/coderabbit-auth.ts @@ -0,0 +1,26 @@ +import { apiClient } from './client' + +export type CodeRabbitStatus = { + connected: boolean + updatedAt?: string +} + +export type CodeRabbitConnectRequest = { + apiKey: string +} + +export async function getCodeRabbitStatus(): Promise { + return apiClient.get('/auth/coderabbit/status') +} + +export async function connectCodeRabbit(data: CodeRabbitConnectRequest): Promise { + await apiClient.post('/auth/coderabbit/connect', data) +} + +export async function disconnectCodeRabbit(): Promise { + await apiClient.delete('/auth/coderabbit/disconnect') +} + +export async function testCodeRabbitConnection(data: CodeRabbitConnectRequest): Promise<{ valid: boolean; error?: string }> { + return apiClient.post<{ valid: boolean; error?: string }, CodeRabbitConnectRequest>('/auth/coderabbit/test', data) +} diff --git a/components/frontend/src/services/api/integrations.ts b/components/frontend/src/services/api/integrations.ts index 60d6addd2..935c1b236 100644 --- a/components/frontend/src/services/api/integrations.ts +++ b/components/frontend/src/services/api/integrations.ts @@ -35,6 +35,11 @@ export type IntegrationsStatus = { valid?: boolean } mcpServers?: Record + coderabbit?: { + connected: boolean + updatedAt?: string + valid?: boolean + } } /** diff --git a/components/frontend/src/services/queries/use-coderabbit.ts b/components/frontend/src/services/queries/use-coderabbit.ts new file mode 100644 index 000000000..e9b2bd941 --- /dev/null +++ b/components/frontend/src/services/queries/use-coderabbit.ts @@ -0,0 +1,31 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import * as coderabbitAuthApi from '../api/coderabbit-auth' + +export function useCodeRabbitStatus() { + return useQuery({ + queryKey: ['coderabbit', 'status'], + queryFn: () => coderabbitAuthApi.getCodeRabbitStatus(), + }) +} + +export function useConnectCodeRabbit() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: coderabbitAuthApi.connectCodeRabbit, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['coderabbit', 'status'] }) + }, + }) +} + +export function useDisconnectCodeRabbit() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: coderabbitAuthApi.disconnectCodeRabbit, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['coderabbit', 'status'] }) + }, + }) +} diff --git a/components/runners/ambient-runner/ambient_runner/platform/auth.py b/components/runners/ambient-runner/ambient_runner/platform/auth.py index f43aa0636..ef6442a82 100755 --- a/components/runners/ambient-runner/ambient_runner/platform/auth.py +++ b/components/runners/ambient-runner/ambient_runner/platform/auth.py @@ -284,6 +284,14 @@ async def fetch_gitlab_token(context: RunnerContext) -> str: return data.get("token", "") +async def fetch_coderabbit_credentials(context: RunnerContext) -> dict: + """Fetch CodeRabbit credentials from backend API.""" + data = await _fetch_credential(context, "coderabbit") + if data.get("apiKey"): + logger.info("Using CodeRabbit credentials from backend") + return data + + async def fetch_token_for_url(context: RunnerContext, url: str) -> str: """Fetch appropriate token based on repository URL host.""" try: @@ -384,6 +392,15 @@ async def populate_runtime_credentials(context: RunnerContext) -> None: if gitlab_creds.get("email"): git_user_email = gitlab_creds["email"] + # CodeRabbit credentials + try: + coderabbit_creds = await fetch_coderabbit_credentials(context) + if coderabbit_creds.get("apiKey"): + os.environ["CODERABBIT_API_KEY"] = coderabbit_creds["apiKey"] + logger.info("Updated CodeRabbit API key in environment") + except Exception as e: + logger.warning(f"Failed to refresh CodeRabbit credentials: {e}") + # GitHub credentials (with user identity — takes precedence) if isinstance(github_creds, Exception): logger.warning(f"Failed to refresh GitHub credentials: {github_creds}") diff --git a/docs/superpowers/plans/2026-04-01-coderabbit-integration.md b/docs/superpowers/plans/2026-04-01-coderabbit-integration.md new file mode 100644 index 000000000..0c6ec0cf6 --- /dev/null +++ b/docs/superpowers/plans/2026-04-01-coderabbit-integration.md @@ -0,0 +1,961 @@ +# CodeRabbit Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add CodeRabbit as a native integration — API key storage, runtime credential injection, and frontend integration card. + +**Architecture:** Backend stores API keys in a per-user K8s Secret (`coderabbit-credentials`), exposes connect/disconnect/status/test endpoints and a runtime credential fetch endpoint. Runner fetches the key at session startup and sets `CODERABBIT_API_KEY` env var. Frontend shows a connection card on the integrations page. + +**Tech Stack:** Go (Gin, K8s client-go), TypeScript (Next.js, React Query, shadcn/ui), Python (runner) + +--- + +### Task 1: Backend — CodeRabbit Auth Handlers + +**Files:** +- Create: `components/backend/handlers/coderabbit_auth.go` + +- [ ] **Step 1: Create the credential struct and storage functions** + +Create `components/backend/handlers/coderabbit_auth.go`: + +```go +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "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" +) + +// CodeRabbitCredentials represents cluster-level CodeRabbit credentials for a user +type CodeRabbitCredentials struct { + UserID string `json:"userId"` + APIKey string `json:"apiKey"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// storeCodeRabbitCredentials stores CodeRabbit credentials in cluster-level Secret +func storeCodeRabbitCredentials(ctx context.Context, creds *CodeRabbitCredentials) error { + if creds == nil || creds.UserID == "" { + return fmt.Errorf("invalid credentials payload") + } + + const secretName = "coderabbit-credentials" + + for i := 0; i < 3; i++ { + secret, err := K8sClient.CoreV1().Secrets(Namespace).Get(ctx, secretName, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + secret = &corev1.Secret{ + ObjectMeta: v1.ObjectMeta{ + Name: secretName, + Namespace: Namespace, + Labels: map[string]string{ + "app": "ambient-code", + "ambient-code.io/provider": "coderabbit", + }, + }, + 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, secretName, 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[creds.UserID] = 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") +} + +// GetCodeRabbitCredentials retrieves cluster-level CodeRabbit credentials for a user +func GetCodeRabbitCredentials(ctx context.Context, userID string) (*CodeRabbitCredentials, error) { + if userID == "" { + return nil, fmt.Errorf("userID is required") + } + + const secretName = "coderabbit-credentials" + + secret, err := K8sClient.CoreV1().Secrets(Namespace).Get(ctx, secretName, v1.GetOptions{}) + if err != nil { + return nil, err + } + + if secret.Data == nil || len(secret.Data[userID]) == 0 { + return nil, nil + } + + var creds CodeRabbitCredentials + if err := json.Unmarshal(secret.Data[userID], &creds); err != nil { + return nil, fmt.Errorf("failed to parse credentials: %w", err) + } + + return &creds, nil +} + +// DeleteCodeRabbitCredentials removes CodeRabbit credentials for a user +func DeleteCodeRabbitCredentials(ctx context.Context, userID string) error { + if userID == "" { + return fmt.Errorf("userID is required") + } + + const secretName = "coderabbit-credentials" + + for i := 0; i < 3; i++ { + secret, err := K8sClient.CoreV1().Secrets(Namespace).Get(ctx, secretName, 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[userID]) == 0 { + return nil + } + + delete(secret.Data, userID) + + 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") +} +``` + +- [ ] **Step 2: Add the HTTP handlers** + +Append to `components/backend/handlers/coderabbit_auth.go`: + +```go +// ConnectCodeRabbit handles POST /api/auth/coderabbit/connect +func ConnectCodeRabbit(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 { + APIKey string `json:"apiKey" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Validate the API key against CodeRabbit's API + valid, err := ValidateCodeRabbitAPIKey(c.Request.Context(), req.APIKey) + if err != nil { + log.Printf("Failed to validate CodeRabbit API key for user %s: %v", userID, err) + c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to validate API key with CodeRabbit"}) + return + } + if !valid { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid CodeRabbit API key"}) + return + } + + creds := &CodeRabbitCredentials{ + UserID: userID, + APIKey: req.APIKey, + UpdatedAt: time.Now(), + } + + if err := storeCodeRabbitCredentials(c.Request.Context(), creds); err != nil { + log.Printf("Failed to store CodeRabbit credentials for user %s: %v", userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save CodeRabbit credentials"}) + return + } + + log.Printf("✓ Stored CodeRabbit credentials for user %s", userID) + c.JSON(http.StatusOK, gin.H{"message": "CodeRabbit connected successfully"}) +} + +// GetCodeRabbitStatus handles GET /api/auth/coderabbit/status +func GetCodeRabbitStatus(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 + } + + creds, err := GetCodeRabbitCredentials(c.Request.Context(), userID) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusOK, gin.H{"connected": false}) + return + } + log.Printf("Failed to get CodeRabbit credentials for user %s: %v", userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check CodeRabbit status"}) + return + } + + if creds == nil { + c.JSON(http.StatusOK, gin.H{"connected": false}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "connected": true, + "updatedAt": creds.UpdatedAt.Format(time.RFC3339), + }) +} + +// DisconnectCodeRabbit handles DELETE /api/auth/coderabbit/disconnect +func DisconnectCodeRabbit(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 err := DeleteCodeRabbitCredentials(c.Request.Context(), userID); err != nil { + log.Printf("Failed to delete CodeRabbit credentials for user %s: %v", userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to disconnect CodeRabbit"}) + return + } + + log.Printf("✓ Deleted CodeRabbit credentials for user %s", userID) + c.JSON(http.StatusOK, gin.H{"message": "CodeRabbit disconnected successfully"}) +} +``` + +- [ ] **Step 3: Verify Go code compiles** + +Run: `cd components/backend && go build ./...` +Expected: Clean build, no errors. + +- [ ] **Step 4: Commit** + +```bash +git add components/backend/handlers/coderabbit_auth.go +git commit -m "feat(backend): add CodeRabbit auth handlers and credential storage" +``` + +--- + +### Task 2: Backend — Validation, Test Connection, and Integration Status + +**Files:** +- Modify: `components/backend/handlers/integration_validation.go` +- Modify: `components/backend/handlers/integrations_status.go` + +- [ ] **Step 1: Add ValidateCodeRabbitAPIKey and TestCodeRabbitConnection** + +Append to `components/backend/handlers/integration_validation.go`: + +```go +// ValidateCodeRabbitAPIKey checks if a CodeRabbit API key is valid +func ValidateCodeRabbitAPIKey(ctx context.Context, apiKey string) (bool, error) { + if apiKey == "" { + return false, fmt.Errorf("API key is empty") + } + + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequestWithContext(ctx, "GET", "https://api.coderabbit.ai/api/v1/health", nil) + if err != nil { + return false, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := client.Do(req) + if err != nil { + return false, fmt.Errorf("request failed") + } + defer resp.Body.Close() + + return resp.StatusCode == http.StatusOK, nil +} + +// TestCodeRabbitConnection handles POST /api/auth/coderabbit/test +func TestCodeRabbitConnection(c *gin.Context) { + var req struct { + APIKey string `json:"apiKey" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + valid, err := ValidateCodeRabbitAPIKey(c.Request.Context(), req.APIKey) + 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 API key"}) + return + } + + c.JSON(http.StatusOK, gin.H{"valid": true, "message": "CodeRabbit connection successful"}) +} +``` + +- [ ] **Step 2: Add CodeRabbit to GetIntegrationsStatus** + +In `components/backend/handlers/integrations_status.go`, add after the MCP server credentials line: + +```go +// CodeRabbit status +response["coderabbit"] = getCodeRabbitStatusForUser(ctx, userID) +``` + +And add the helper function: + +```go +func getCodeRabbitStatusForUser(ctx context.Context, userID string) gin.H { + creds, err := GetCodeRabbitCredentials(ctx, userID) + if err != nil || creds == nil { + return gin.H{"connected": false} + } + + return gin.H{ + "connected": true, + "updatedAt": creds.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + "valid": true, + } +} +``` + +- [ ] **Step 3: Verify Go code compiles** + +Run: `cd components/backend && go build ./...` +Expected: Clean build, no errors. + +- [ ] **Step 4: Commit** + +```bash +git add components/backend/handlers/integration_validation.go components/backend/handlers/integrations_status.go +git commit -m "feat(backend): add CodeRabbit validation, test endpoint, and integration status" +``` + +--- + +### Task 3: Backend — Runtime Credential Fetch and Route Registration + +**Files:** +- Modify: `components/backend/handlers/runtime_credentials.go` +- Modify: `components/backend/routes.go` + +- [ ] **Step 1: Add GetCodeRabbitCredentialsForSession** + +Append to `components/backend/handlers/runtime_credentials.go`: + +```go +// GetCodeRabbitCredentialsForSession handles GET /api/projects/:project/agentic-sessions/:session/credentials/coderabbit +func GetCodeRabbitCredentialsForSession(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 + } + + gvr := GetAgenticSessionV1Alpha1Resource() + obj, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), session, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) + return + } + log.Printf("Failed to get session %s/%s: %v", project, session, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get session"}) + return + } + + userID, found, err := unstructured.NestedString(obj.Object, "spec", "userContext", "userId") + if !found || err != nil || userID == "" { + log.Printf("Failed to extract userID from session %s/%s: found=%v, err=%v", project, session, found, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "User ID not found in session"}) + return + } + + authenticatedUserID := c.GetString("userID") + if authenticatedUserID != "" && authenticatedUserID != userID { + log.Printf("RBAC violation: user %s attempted to access credentials for session owned by %s", authenticatedUserID, userID) + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied: session belongs to different user"}) + return + } + + creds, err := GetCodeRabbitCredentials(c.Request.Context(), userID) + if err != nil { + log.Printf("Failed to get CodeRabbit credentials for user %s: %v", userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get CodeRabbit credentials"}) + return + } + + if creds == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "CodeRabbit credentials not configured"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "apiKey": creds.APIKey, + }) +} +``` + +- [ ] **Step 2: Register routes** + +In `components/backend/routes.go`, add cluster-scoped routes after the GitLab test line (`api.POST("/auth/gitlab/test", ...)`): + +```go +// Cluster-level CodeRabbit (user-scoped) +api.POST("/auth/coderabbit/connect", handlers.ConnectCodeRabbit) +api.GET("/auth/coderabbit/status", handlers.GetCodeRabbitStatus) +api.DELETE("/auth/coderabbit/disconnect", handlers.DisconnectCodeRabbit) +api.POST("/auth/coderabbit/test", handlers.TestCodeRabbitConnection) +``` + +Add the runtime credential route after the existing credentials routes in the `projectGroup` section (after the MCP credentials line): + +```go +projectGroup.GET("/agentic-sessions/:sessionName/credentials/coderabbit", handlers.GetCodeRabbitCredentialsForSession) +``` + +- [ ] **Step 3: Verify Go code compiles** + +Run: `cd components/backend && go build ./...` +Expected: Clean build, no errors. + +- [ ] **Step 4: Commit** + +```bash +git add components/backend/handlers/runtime_credentials.go components/backend/routes.go +git commit -m "feat(backend): add CodeRabbit runtime credential fetch and route registration" +``` + +--- + +### Task 4: Backend — Tests + +**Files:** +- Create: `components/backend/handlers/coderabbit_auth_test.go` +- Modify: `components/backend/tests/constants/labels.go` + +- [ ] **Step 1: Add test label constant** + +In `components/backend/tests/constants/labels.go`, add to the constants block: + +```go +LabelCodeRabbitAuth = "coderabbit-auth" +``` + +- [ ] **Step 2: Create test file** + +Create `components/backend/handlers/coderabbit_auth_test.go`. Follow the Jira test patterns — use Ginkgo/Gomega with fake K8s clientset. Test cases: + +1. `ConnectCodeRabbit` — stores credentials when valid API key provided +2. `ConnectCodeRabbit` — rejects empty API key +3. `ConnectCodeRabbit` — rejects unauthenticated requests +4. `GetCodeRabbitStatus` — returns `connected: true` when credentials exist +5. `GetCodeRabbitStatus` — returns `connected: false` when no credentials +6. `DisconnectCodeRabbit` — removes credentials +7. `DisconnectCodeRabbit` — succeeds when no credentials exist (idempotent) + +Note: Mock the `ValidateCodeRabbitAPIKey` call — do not make real HTTP requests in tests. Use `httptest.NewServer` to stub the CodeRabbit health endpoint. + +Reference the existing test setup in `components/backend/handlers/` for how other handler tests configure the fake K8s client and Gin test context. + +- [ ] **Step 3: Run tests** + +Run: `cd components/backend && go test ./handlers/ -run CodeRabbit -v` +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add components/backend/handlers/coderabbit_auth_test.go components/backend/tests/constants/labels.go +git commit -m "test(backend): add CodeRabbit auth handler tests" +``` + +--- + +### Task 5: Frontend — API Client and React Query Hooks + +**Files:** +- Create: `components/frontend/src/services/api/coderabbit-auth.ts` +- Create: `components/frontend/src/services/queries/use-coderabbit.ts` +- Modify: `components/frontend/src/services/api/integrations.ts` + +- [ ] **Step 1: Create API client** + +Create `components/frontend/src/services/api/coderabbit-auth.ts`: + +```typescript +import { apiClient } from './client' + +export type CodeRabbitStatus = { + connected: boolean + updatedAt?: string +} + +export type CodeRabbitConnectRequest = { + apiKey: string +} + +export async function getCodeRabbitStatus(): Promise { + return apiClient.get('/auth/coderabbit/status') +} + +export async function connectCodeRabbit(data: CodeRabbitConnectRequest): Promise { + await apiClient.post('/auth/coderabbit/connect', data) +} + +export async function disconnectCodeRabbit(): Promise { + await apiClient.delete('/auth/coderabbit/disconnect') +} + +export async function testCodeRabbitConnection(data: CodeRabbitConnectRequest): Promise<{ valid: boolean; error?: string }> { + return apiClient.post<{ valid: boolean; error?: string }, CodeRabbitConnectRequest>('/auth/coderabbit/test', data) +} +``` + +- [ ] **Step 2: Create React Query hooks** + +Create `components/frontend/src/services/queries/use-coderabbit.ts`: + +```typescript +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import * as coderabbitAuthApi from '../api/coderabbit-auth' + +export function useCodeRabbitStatus() { + return useQuery({ + queryKey: ['coderabbit', 'status'], + queryFn: () => coderabbitAuthApi.getCodeRabbitStatus(), + }) +} + +export function useConnectCodeRabbit() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: coderabbitAuthApi.connectCodeRabbit, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['coderabbit', 'status'] }) + }, + }) +} + +export function useDisconnectCodeRabbit() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: coderabbitAuthApi.disconnectCodeRabbit, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['coderabbit', 'status'] }) + }, + }) +} +``` + +- [ ] **Step 3: Add CodeRabbit to IntegrationsStatus type** + +In `components/frontend/src/services/api/integrations.ts`, add to the `IntegrationsStatus` type: + +```typescript +coderabbit?: { + connected: boolean + updatedAt?: string + valid?: boolean +} +``` + +- [ ] **Step 4: Verify frontend builds** + +Run: `cd components/frontend && npx tsc --noEmit` +Expected: No type errors. + +- [ ] **Step 5: Commit** + +```bash +git add components/frontend/src/services/api/coderabbit-auth.ts components/frontend/src/services/queries/use-coderabbit.ts components/frontend/src/services/api/integrations.ts +git commit -m "feat(frontend): add CodeRabbit API client and React Query hooks" +``` + +--- + +### Task 6: Frontend — Next.js Proxy Routes + +**Files:** +- Create: `components/frontend/src/app/api/auth/coderabbit/connect/route.ts` +- Create: `components/frontend/src/app/api/auth/coderabbit/status/route.ts` +- Create: `components/frontend/src/app/api/auth/coderabbit/disconnect/route.ts` +- Create: `components/frontend/src/app/api/auth/coderabbit/test/route.ts` + +- [ ] **Step 1: Create connect proxy route** + +Create `components/frontend/src/app/api/auth/coderabbit/connect/route.ts`: + +```typescript +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/coderabbit/connect`, { + method: 'POST', + headers, + body, + }) + + const data = await resp.text() + return new Response(data, { status: resp.status, headers: { 'Content-Type': 'application/json' } }) +} +``` + +- [ ] **Step 2: Create status proxy route** + +Create `components/frontend/src/app/api/auth/coderabbit/status/route.ts`: + +```typescript +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/coderabbit/status`, { + method: 'GET', + headers, + }) + + const data = await resp.text() + return new Response(data, { status: resp.status, headers: { 'Content-Type': 'application/json' } }) +} +``` + +- [ ] **Step 3: Create disconnect proxy route** + +Create `components/frontend/src/app/api/auth/coderabbit/disconnect/route.ts`: + +```typescript +import { BACKEND_URL } from '@/lib/config' +import { buildForwardHeadersAsync } from '@/lib/auth' + +export async function DELETE(request: Request) { + const headers = await buildForwardHeadersAsync(request) + + const resp = await fetch(`${BACKEND_URL}/auth/coderabbit/disconnect`, { + method: 'DELETE', + headers, + }) + + const data = await resp.text() + return new Response(data, { status: resp.status, headers: { 'Content-Type': 'application/json' } }) +} +``` + +- [ ] **Step 4: Create test proxy route** + +Create `components/frontend/src/app/api/auth/coderabbit/test/route.ts`: + +```typescript +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/coderabbit/test`, { + method: 'POST', + headers, + body, + }) + + const data = await resp.text() + return new Response(data, { status: resp.status, headers: { 'Content-Type': 'application/json' } }) +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add components/frontend/src/app/api/auth/coderabbit/ +git commit -m "feat(frontend): add CodeRabbit Next.js proxy routes" +``` + +--- + +### Task 7: Frontend — Connection Card Component + +**Files:** +- Create: `components/frontend/src/components/coderabbit-connection-card.tsx` + +- [ ] **Step 1: Create the CodeRabbit connection card** + +Create `components/frontend/src/components/coderabbit-connection-card.tsx`. Follow the `jira-connection-card.tsx` pattern exactly. Key differences from Jira: + +- Single field: API key (not URL + email + token) +- No pre-population of fields +- Icon: use a code review icon (e.g., `` from lucide-react or a custom SVG) +- Description: "Connect to CodeRabbit for AI-powered code review" +- Status shows connected/not connected with `updatedAt` +- Link to CodeRabbit API key docs: `https://app.coderabbit.ai/settings/api-keys` + +Props type: + +```typescript +type Props = { + status?: { + connected: boolean + updatedAt?: string + valid?: boolean + } + onRefresh?: () => void +} +``` + +The card should: +1. Show a connect button when not connected +2. On click, show a form with a single password input for the API key (with show/hide toggle) +3. On submit, call `connectCodeRabbit({ apiKey })` +4. When connected, show Edit and Disconnect buttons +5. Use `useConnectCodeRabbit()` and `useDisconnectCodeRabbit()` hooks +6. Use `toast` from `sonner` for success/error notifications + +- [ ] **Step 2: Verify frontend builds** + +Run: `cd components/frontend && npx tsc --noEmit` +Expected: No type errors. + +- [ ] **Step 3: Commit** + +```bash +git add components/frontend/src/components/coderabbit-connection-card.tsx +git commit -m "feat(frontend): add CodeRabbit connection card component" +``` + +--- + +### Task 8: Frontend — Wire Up Integration Pages + +**Files:** +- Modify: `components/frontend/src/app/integrations/IntegrationsClient.tsx` +- Modify: `components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/settings/integrations-panel.tsx` + +- [ ] **Step 1: Add CodeRabbit card to integrations page** + +In `components/frontend/src/app/integrations/IntegrationsClient.tsx`: + +1. Add import: `import { CodeRabbitConnectionCard } from '@/components/coderabbit-connection-card'` +2. Add the card inside the grid div, after ``: + +```tsx + +``` + +- [ ] **Step 2: Add CodeRabbit to integrations panel** + +In `components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/settings/integrations-panel.tsx`: + +1. Add after `const jiraConfigured = ...`: + +```typescript +const coderabbitConfigured = integrationsStatus?.coderabbit?.connected ?? false; +``` + +2. Add to the `integrations` array: + +```typescript +{ + key: "coderabbit", + name: "CodeRabbit", + configured: coderabbitConfigured, + configuredMessage: "Authenticated. AI code review enabled in sessions.", +}, +``` + +- [ ] **Step 3: Verify frontend builds** + +Run: `cd components/frontend && npx tsc --noEmit` +Expected: No type errors. + +- [ ] **Step 4: Commit** + +```bash +git add components/frontend/src/app/integrations/IntegrationsClient.tsx components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/settings/integrations-panel.tsx +git commit -m "feat(frontend): wire CodeRabbit card into integrations pages" +``` + +--- + +### Task 9: Frontend — Tests + +**Files:** +- Modify: `components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/settings/__tests__/integrations-panel.test.tsx` + +- [ ] **Step 1: Update integrations panel test** + +Add CodeRabbit to the mock integrations status data and assert it renders. Find the existing mock for `useIntegrationsStatus` and add: + +```typescript +coderabbit: { connected: true, updatedAt: '2026-04-01T00:00:00Z', valid: true } +``` + +Add a test assertion that "CodeRabbit" text appears in the rendered output. + +- [ ] **Step 2: Run frontend tests** + +Run: `cd components/frontend && npx vitest run --reporter=verbose -- integrations-panel` +Expected: All tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/settings/__tests__/integrations-panel.test.tsx +git commit -m "test(frontend): add CodeRabbit to integrations panel tests" +``` + +--- + +### Task 10: Runner — CodeRabbit Credential Injection + +**Files:** +- Modify: `components/runners/ambient-runner/ambient_runner/platform/auth.py` + +- [ ] **Step 1: Add fetch function** + +Add after the `fetch_gitlab_credentials` function in `auth.py`: + +```python +async def fetch_coderabbit_credentials(context: RunnerContext) -> dict: + """Fetch CodeRabbit credentials from backend API.""" + data = await _fetch_credential(context, "coderabbit") + if data.get("apiKey"): + logger.info("Using CodeRabbit credentials from backend") + return data +``` + +- [ ] **Step 2: Add to populate_runtime_credentials** + +In the `populate_runtime_credentials` function, add after the GitLab credentials block (before the GitHub credentials block that sets git identity): + +```python +# CodeRabbit credentials +try: + coderabbit_creds = await fetch_coderabbit_credentials(context) + if coderabbit_creds.get("apiKey"): + os.environ["CODERABBIT_API_KEY"] = coderabbit_creds["apiKey"] + logger.info("Updated CodeRabbit API key in environment") +except Exception as e: + logger.warning(f"Failed to refresh CodeRabbit credentials: {e}") +``` + +- [ ] **Step 3: Run runner tests** + +Run: `cd components/runners/ambient-runner && python -m pytest tests/ -v` +Expected: All existing tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add components/runners/ambient-runner/ambient_runner/platform/auth.py +git commit -m "feat(runner): add CodeRabbit credential injection" +``` + +--- + +### Task 11: Final Verification + +- [ ] **Step 1: Run backend build and tests** + +```bash +cd components/backend && go build ./... && go test ./handlers/ -run CodeRabbit -v +``` + +- [ ] **Step 2: Run frontend build** + +```bash +cd components/frontend && npm run build +``` + +- [ ] **Step 3: Run frontend tests** + +```bash +cd components/frontend && npx vitest run --reporter=verbose +``` + +- [ ] **Step 4: Run lints** + +```bash +cd /path/to/worktree && pre-commit run --all-files +``` + +- [ ] **Step 5: Final commit if any lint fixes needed** + +```bash +git add -A && git commit -m "chore: lint fixes" +``` diff --git a/docs/superpowers/specs/2026-04-01-coderabbit-integration-design.md b/docs/superpowers/specs/2026-04-01-coderabbit-integration-design.md new file mode 100644 index 000000000..085a99a31 --- /dev/null +++ b/docs/superpowers/specs/2026-04-01-coderabbit-integration-design.md @@ -0,0 +1,115 @@ +# CodeRabbit Integration for ACP + +## Overview + +Add CodeRabbit as a native integration in the Ambient Code Platform, enabling AI-powered code review inside agentic sessions. Users store a CodeRabbit API key via the integrations page; the runner injects it as `CODERABBIT_API_KEY` so the `coderabbit` CLI can run local reviews. The repo's `.pre-commit-config.yaml` enforces reviews at commit time. + +## Goals + +- Store and manage CodeRabbit API keys per user (cluster-scoped K8s Secrets) +- Inject credentials into sessions so the CLI authenticates automatically +- Provide a frontend integration card consistent with existing integrations (Jira, GitLab) +- Enable a local review-resolve loop: agent makes changes, runs `coderabbit review --type uncommitted --prompt-only`, fixes findings, repeats until clean — all before any push + +## Non-Goals + +- GitHub App detection for CodeRabbit (API key only for now) +- In-session `pre-commit install` enforcement (separate PR) +- Adding the `coderabbit` binary to the runner Dockerfile (separate PR) +- MCP server wrapping around the CLI + +## Architecture + +### Session Flow + +``` +User configures API key → Backend stores in K8s Secret → +Session starts → Runner fetches /credentials/coderabbit → +Sets CODERABBIT_API_KEY env var → Agent uses coderabbit CLI → +Pre-commit hook enforces review before commits +``` + +### Backend + +**New file:** `components/backend/handlers/coderabbit_auth.go` + +Credential struct: + +```go +type CodeRabbitCredentials struct { + UserID string `json:"userId"` + APIKey string `json:"apiKey"` + UpdatedAt time.Time `json:"updatedAt"` +} +``` + +K8s Secret: `coderabbit-credentials` (cluster-scoped, keyed by `userID`). + +Endpoints: + +| Method | Path | Handler | Description | +|--------|------|---------|-------------| +| POST | `/api/auth/coderabbit/connect` | ConnectCodeRabbit | Validate + store API key | +| GET | `/api/auth/coderabbit/status` | GetCodeRabbitStatus | Connection status | +| DELETE | `/api/auth/coderabbit/disconnect` | DisconnectCodeRabbit | Remove credentials | +| POST | `/api/auth/coderabbit/test` | TestCodeRabbitConnection | Validate without storing | +| GET | `/api/projects/:project/agentic-sessions/:session/credentials/coderabbit` | GetCodeRabbitCredentialsForSession | Runtime credential fetch | + +All handlers follow existing patterns: `GetK8sClientsForRequest(c)` for user auth, per-user secret isolation, conflict-retry on Secret updates, RBAC validation on runtime credential fetch. + +API key validation: `GET https://api.coderabbit.ai/api/v1/health` with `Authorization: Bearer `. A 200 response confirms the key is valid. + +`GetIntegrationsStatus` updated to include `coderabbit` in its response. + +### Runner + +At session startup, the runner fetches `GET /credentials/coderabbit`. If credentials exist, it sets `CODERABBIT_API_KEY` in the session process environment. No config file needed — the CLI reads the env var directly. + +Graceful degradation: if no credentials are configured, the runner skips injection. The pre-commit hook and CLI skip gracefully when no auth is available. + +### Frontend + +**New files:** + +| File | Purpose | +|------|---------| +| `components/coderabbit-connection-card.tsx` | Integration card UI | +| `services/api/coderabbit-auth.ts` | API client functions | +| `services/queries/use-coderabbit.ts` | React Query hooks | +| `app/api/auth/coderabbit/connect/route.ts` | Next.js proxy route | +| `app/api/auth/coderabbit/disconnect/route.ts` | Next.js proxy route | +| `app/api/auth/coderabbit/status/route.ts` | Next.js proxy route | +| `app/api/auth/coderabbit/test/route.ts` | Next.js proxy route | + +**Modified files:** + +| File | Change | +|------|--------| +| `IntegrationsClient.tsx` | Add `` to grid | +| `services/api/integrations.ts` | Add `coderabbit` to `IntegrationsStatus` type | +| `integrations-panel.tsx` | Add CodeRabbit to session settings | + +Card UI: single-field connect form (API key with show/hide toggle), status indicator, edit/disconnect buttons. Follows Jira card pattern exactly. + +### Pre-commit Hook (already committed) + +- `scripts/pre-commit/coderabbit-review.sh` — resolves CLI binary, checks auth, runs `coderabbit review --type uncommitted --prompt-only` with 5-minute timeout +- `.coderabbit.yaml` — project-specific review config with path instructions, custom pre-merge checks (performance, security, K8s safety), and tool configuration +- Registered in `.pre-commit-config.yaml` + +### Tests + +**Backend:** `components/backend/handlers/coderabbit_auth_test.go` (Ginkgo) + +- Connect with valid/invalid API key +- Status when connected/disconnected +- Disconnect +- Per-user secret isolation +- Runtime credential fetch with RBAC validation + +**Frontend:** Update `integrations-panel.test.tsx` to include CodeRabbit card. + +## Future Work + +- Default `pre-commit install` to enabled when CodeRabbit integration is configured (separate PR) +- Add `coderabbit` binary to runner Dockerfile (separate PR) diff --git a/scripts/pre-commit/coderabbit-review.sh b/scripts/pre-commit/coderabbit-review.sh new file mode 100755 index 000000000..f6f75524b --- /dev/null +++ b/scripts/pre-commit/coderabbit-review.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# coderabbit-review.sh — run CodeRabbit CLI review on staged changes. +# Skips gracefully if the CLI or auth is not available. +# Treats transient failures (rate limits, network errors) as warnings. +set -euo pipefail + +# Resolve binary name +CR="" +if command -v cr &>/dev/null; then + CR="cr" +elif command -v coderabbit &>/dev/null; then + CR="coderabbit" +else + echo "CodeRabbit CLI not found — skipping review (install from https://cli.coderabbit.ai)" + exit 0 +fi + +# Skip if nothing is staged +if git diff --cached --quiet; then + exit 0 +fi + +# Require auth: API key or OAuth login +if [ -z "${CODERABBIT_API_KEY:-}" ]; then + if ! "$CR" auth status &>/dev/null; then + echo "CODERABBIT_API_KEY not set and not logged in — skipping CodeRabbit review" + exit 0 + fi +fi + +# Run review; capture output to distinguish findings from transient errors +OUTPUT=$(timeout 300 "$CR" review --type uncommitted --prompt-only 2>&1) || EXIT_CODE=$? +EXIT_CODE=${EXIT_CODE:-0} + +echo "$OUTPUT" + +if [ "$EXIT_CODE" -eq 0 ]; then + exit 0 +fi + +# Rate limits and network errors should warn, not block +if echo "$OUTPUT" | grep -qi "rate limit\|network\|timeout\|connection"; then + echo "CodeRabbit: transient error (see above) — not blocking commit" + exit 0 +fi + +# Actual review findings — block the commit +exit "$EXIT_CODE"