From 0c7e0f56800613ae6f2b400df2176e0487f4229a Mon Sep 17 00:00:00 2001 From: Ambient Code Bot Date: Tue, 14 Apr 2026 21:47:26 -0400 Subject: [PATCH 1/6] feat: add CodeRabbit integration for AI-powered code review Full-stack integration following the Jira pattern and PR #1307 conventions. Implements infrastructure for ADR-0008 (automated inner-loop review). Backend (Go): - Auth handlers: connect, status, disconnect, test (K8s Secret storage) - API key validation against CodeRabbit health API with error differentiation (401/403 = invalid key, 5xx = upstream error) - Runtime credential endpoint with RBAC for session pods - Unified integrations status includes CodeRabbit - 16 Ginkgo tests Frontend (Next.js + React): - Informational-first connection card: public repos free via GitHub App, API key collapsed under "Private repository access" with billing warning - API client layer + React Query hooks with dual cache invalidation - 4 Next.js proxy routes - Wired into IntegrationsClient grid and session integrations panel Runner (Python): - fetch_coderabbit_credentials via shared _fetch_credential helper - CODERABBIT_API_KEY injected into session env via asyncio.gather - Cleared on turn completion Pre-commit hook: - Runs coderabbit review --agent on staged changes - Supports both CODERABBIT_API_KEY env and cr auth login session - CLI reads env var directly (no --api-key in process listing) - Skips gracefully when CLI/auth/changes unavailable CI + Testing: - GHA smoke test: validates config, runs live review, tests hook behavior (actions pinned to SHAs, permissions scoped) - Integration test script: 9/9 passing against dev cluster Docs: - Starlight guide: public vs private repos, local dev, session flow - ADR-0008: automated code reviews via inner-loop + Mergify - PR #1307 impact analysis Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/coderabbit-smoke-test.yml | 129 ++++++++ .pre-commit-config.yaml | 11 + .../backend/handlers/coderabbit_auth.go | 298 +++++++++++++++++ .../backend/handlers/coderabbit_auth_test.go | 313 ++++++++++++++++++ .../handlers/integration_validation.go | 27 ++ .../backend/handlers/integrations_status.go | 16 + .../backend/handlers/runtime_credentials.go | 37 +++ components/backend/routes.go | 7 + components/backend/tests/constants/labels.go | 29 +- .../app/api/auth/coderabbit/connect/route.ts | 16 + .../api/auth/coderabbit/disconnect/route.ts | 14 + .../app/api/auth/coderabbit/status/route.ts | 14 + .../src/app/api/auth/coderabbit/test/route.ts | 16 + .../app/integrations/IntegrationsClient.tsx | 5 + .../settings/integrations-panel.tsx | 11 +- .../components/coderabbit-connection-card.tsx | 238 +++++++++++++ .../src/services/api/coderabbit-auth.ts | 38 +++ .../frontend/src/services/api/integrations.ts | 5 + .../src/services/queries/use-coderabbit.ts | 33 ++ .../ambient_runner/platform/auth.py | 30 +- docs/pr-1307-impact-analysis.md | 34 ++ docs/src/content/docs/features/coderabbit.md | 106 ++++++ scripts/pre-commit/coderabbit-review.sh | 63 ++++ scripts/test-coderabbit-integration.sh | 230 +++++++++++++ .../checklists/requirements.md | 34 ++ specs/001-coderabbit-integration/spec.md | 122 +++++++ 26 files changed, 1860 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/coderabbit-smoke-test.yml create mode 100644 components/backend/handlers/coderabbit_auth.go create mode 100644 components/backend/handlers/coderabbit_auth_test.go create mode 100644 components/frontend/src/app/api/auth/coderabbit/connect/route.ts create mode 100644 components/frontend/src/app/api/auth/coderabbit/disconnect/route.ts create mode 100644 components/frontend/src/app/api/auth/coderabbit/status/route.ts create mode 100644 components/frontend/src/app/api/auth/coderabbit/test/route.ts create mode 100644 components/frontend/src/components/coderabbit-connection-card.tsx create mode 100644 components/frontend/src/services/api/coderabbit-auth.ts create mode 100644 components/frontend/src/services/queries/use-coderabbit.ts create mode 100644 docs/pr-1307-impact-analysis.md create mode 100644 docs/src/content/docs/features/coderabbit.md create mode 100755 scripts/pre-commit/coderabbit-review.sh create mode 100755 scripts/test-coderabbit-integration.sh create mode 100644 specs/001-coderabbit-integration/checklists/requirements.md create mode 100644 specs/001-coderabbit-integration/spec.md diff --git a/.github/workflows/coderabbit-smoke-test.yml b/.github/workflows/coderabbit-smoke-test.yml new file mode 100644 index 000000000..a51dd353e --- /dev/null +++ b/.github/workflows/coderabbit-smoke-test.yml @@ -0,0 +1,129 @@ +name: CodeRabbit Integration Smoke Test + +# Validates the CodeRabbit integration works end-to-end: +# - CLI installs and authenticates +# - Can review files against the real CodeRabbit API +# - Config file (.coderabbit.yaml) is valid + +on: + pull_request: + branches: [main] + paths: + - '.coderabbit.yaml' + - 'components/backend/handlers/coderabbit_auth.go' + - 'components/backend/handlers/integration_validation.go' + - 'components/frontend/src/components/coderabbit-connection-card.tsx' + - 'components/runners/ambient-runner/ambient_runner/platform/auth.py' + - 'scripts/pre-commit/coderabbit-review.sh' + - '.github/workflows/coderabbit-smoke-test.yml' + + workflow_dispatch: + + schedule: + - cron: '0 6 * * 1' # Weekly Monday 6am UTC + +permissions: + contents: read + +concurrency: + group: coderabbit-smoke-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + smoke-test: + name: CodeRabbit Smoke Test + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: '20' + + - name: Install CodeRabbit CLI + run: npm install -g coderabbit + + - name: Verify CLI installed + run: | + coderabbit --version + echo "CLI binary: $(which coderabbit)" + + - name: Validate .coderabbit.yaml schema + run: | + echo "=== Validating .coderabbit.yaml ===" + python3 -c " + import yaml, sys + with open('.coderabbit.yaml') as f: + config = yaml.safe_load(f) + assert 'reviews' in config, 'Missing reviews section' + assert 'language' in config, 'Missing language field' + print(f'Config valid: {len(config)} top-level keys') + print(f'Reviews profile: {config[\"reviews\"].get(\"profile\", \"not set\")}') + print(f'Auto review: {config[\"reviews\"].get(\"auto_review\", {}).get(\"enabled\", False)}') + print(f'Tools configured: {len(config[\"reviews\"].get(\"tools\", {}))}') + " + echo "PASSED: .coderabbit.yaml is valid" + + - name: Run CodeRabbit review on config file + env: + CODERABBIT_API_KEY: ${{ secrets.CODERABBIT_API_KEY }} + run: | + echo "=== Running CodeRabbit review against real API ===" + + # Skip if no API key (fork PRs, missing secret) + if [ -z "$CODERABBIT_API_KEY" ]; then + echo "CODERABBIT_API_KEY not set - skipping live review" + echo "This is expected for fork PRs or when the secret is not configured" + exit 0 + fi + + # Review the config file itself using agent mode for structured output + EXIT_CODE=0 + OUTPUT=$(coderabbit review \ + --agent \ + --files .coderabbit.yaml \ + --api-key "$CODERABBIT_API_KEY" \ + 2>&1) || EXIT_CODE=$? + + echo "$OUTPUT" + + # Auth errors are fatal + if echo "$OUTPUT" | grep -qiE "unauthorized|forbidden|invalid.*key"; then + echo "FAILED: CodeRabbit API key appears invalid" + exit 1 + fi + + # Non-zero exit from CLI is a real failure + if [ "$EXIT_CODE" -ne 0 ]; then + echo "FAILED: coderabbit review exited $EXIT_CODE" + exit 1 + fi + + echo "PASSED: CodeRabbit API responded successfully" + + - name: Verify pre-commit hook skips gracefully + run: | + echo "=== Testing pre-commit hook graceful skip ===" + unset CODERABBIT_API_KEY + + chmod +x scripts/pre-commit/coderabbit-review.sh + OUTPUT=$(scripts/pre-commit/coderabbit-review.sh 2>&1) + EXIT_CODE=$? + + echo "$OUTPUT" + + if [ "$EXIT_CODE" -ne 0 ]; then + echo "FAILED: Hook should exit 0 when skipping" + exit 1 + fi + + if ! echo "$OUTPUT" | grep -qiE "not found|not set|skipping"; then + echo "FAILED: Hook should print a skip message" + exit 1 + fi + + echo "PASSED: Pre-commit hook skips gracefully" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f53170109..21d0a215a 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 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 + stages: [pre-commit] + # ── 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..816690099 --- /dev/null +++ b/components/backend/handlers/coderabbit_auth.go @@ -0,0 +1,298 @@ +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"` +} + +// ValidateCodeRabbitAPIKey is a package-level var for test mockability. +// Signature matches ValidateGitHubToken, ValidateGitLabToken, etc. +var ValidateCodeRabbitAPIKey = validateCodeRabbitAPIKeyImpl + +func validateCodeRabbitAPIKeyImpl(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") + } + + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := client.Do(req) + if err != nil { + return false, fmt.Errorf("request failed: %w", networkError(err)) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + return true, nil + case http.StatusUnauthorized, http.StatusForbidden: + return false, nil + default: + return false, fmt.Errorf("upstream error: status %d", resp.StatusCode) + } +} + +// 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 against CodeRabbit health 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 + } + + // 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..e7ffef25d --- /dev/null +++ b/components/backend/handlers/coderabbit_auth_test.go @@ -0,0 +1,313 @@ +//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/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 Handler", Label(test_constants.LabelUnit, test_constants.LabelHandlers, test_constants.LabelCodeRabbitAuth), func() { + var ( + httpUtils *test_utils.HTTPTestUtils + k8sUtils *test_utils.K8sTestUtils + originalNamespace string + originalValidateCodeRabbitAPIKey func(context.Context, string) (bool, error) + testToken string + ) + + BeforeEach(func() { + logger.Log("Setting up CodeRabbit Auth Handler test") + + originalNamespace = Namespace + originalValidateCodeRabbitAPIKey = ValidateCodeRabbitAPIKey + ValidateCodeRabbitAPIKey = func(_ context.Context, _ string) (bool, error) { return true, nil } + + // Use centralized handler dependencies setup + k8sUtils = test_utils.NewK8sTestUtils(false, *config.TestNamespace) + SetupHandlerDependencies(k8sUtils) + + // coderabbit_auth.go uses Namespace (backend namespace) for some 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 + ValidateCodeRabbitAPIKey = originalValidateCodeRabbitAPIKey + + // Clean up created namespace (best-effort) + if k8sUtils != nil { + _ = k8sUtils.K8sClient.CoreV1().Namespaces().Delete(context.Background(), *config.TestNamespace, metav1.DeleteOptions{}) + } + }) + + Context("Connection Management", func() { + Describe("ConnectCodeRabbit", func() { + It("Should require authentication", func() { + requestBody := map[string]interface{}{ + "apiKey": "cr_test_key_1234567890", + } + + context := httpUtils.CreateTestGinContext("POST", "/api/auth/coderabbit/connect", requestBody) + // Don't set auth header + httpUtils.SetUserContext("test-user", "Test User", "test@example.com") + + ConnectCodeRabbit(context) + + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + httpUtils.AssertErrorMessage("Invalid or missing token") + }) + + It("Should require user authentication", func() { + requestBody := map[string]interface{}{ + "apiKey": "cr_test_key_1234567890", + } + + context := httpUtils.CreateTestGinContext("POST", "/api/auth/coderabbit/connect", requestBody) + // Don't set auth header or user context + + ConnectCodeRabbit(context) + + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + httpUtils.AssertJSONContains(map[string]interface{}{ + "error": "Invalid or missing token", + }) + }) + + It("Should reject empty API key", func() { + requestBody := map[string]interface{}{ + "apiKey": "", + } + + context := httpUtils.CreateTestGinContext("POST", "/api/auth/coderabbit/connect", requestBody) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetUserContext("test-user", "Test User", "test@example.com") + + ConnectCodeRabbit(context) + + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + }) + + It("Should reject missing API key field", func() { + requestBody := map[string]interface{}{ + // apiKey missing + } + + context := httpUtils.CreateTestGinContext("POST", "/api/auth/coderabbit/connect", requestBody) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetUserContext("test-user", "Test User", "test@example.com") + + ConnectCodeRabbit(context) + + // Gin binding returns detailed validation error message + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + }) + + It("Should reject invalid API key", func() { + // Mock validation to return false + ValidateCodeRabbitAPIKey = func(_ context.Context, _ string) (bool, error) { return false, nil } + + requestBody := map[string]interface{}{ + "apiKey": "invalid_key_123", + } + + context := httpUtils.CreateTestGinContext("POST", "/api/auth/coderabbit/connect", requestBody) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetUserContext("test-user", "Test User", "test@example.com") + + ConnectCodeRabbit(context) + + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + httpUtils.AssertJSONContains(map[string]interface{}{ + "error": "Invalid CodeRabbit API key", + }) + }) + + It("Should store credentials successfully with valid API key", func() { + // Mock validation to return true + ValidateCodeRabbitAPIKey = func(_ context.Context, _ string) (bool, error) { return true, nil } + + requestBody := map[string]interface{}{ + "apiKey": "cr_valid_key_1234567890", + } + + context := httpUtils.CreateTestGinContext("POST", "/api/auth/coderabbit/connect", requestBody) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetUserContext("test-user", "Test User", "test@example.com") + + ConnectCodeRabbit(context) + + httpUtils.AssertHTTPStatus(http.StatusOK) + httpUtils.AssertJSONContains(map[string]interface{}{ + "message": "CodeRabbit connected successfully", + }) + }) + + It("Should handle invalid user ID type", func() { + requestBody := map[string]interface{}{ + "apiKey": "cr_test_key_1234567890", + } + + context := httpUtils.CreateTestGinContext("POST", "/api/auth/coderabbit/connect", requestBody) + httpUtils.SetAuthHeader(testToken) + context.Set("userID", 123) // Invalid type (should be string) + + ConnectCodeRabbit(context) + + // GetString returns empty string for non-string types, triggers auth required error + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + httpUtils.AssertErrorMessage("User authentication required") + }) + + It("Should require valid JSON body", func() { + context := httpUtils.CreateTestGinContext("POST", "/api/auth/coderabbit/connect", "invalid-json") + httpUtils.SetAuthHeader(testToken) + httpUtils.SetUserContext("test-user", "Test User", "test@example.com") + + ConnectCodeRabbit(context) + + // Gin binding returns detailed validation error + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + }) + }) + + Describe("GetCodeRabbitStatus", func() { + It("Should require authentication", func() { + context := httpUtils.CreateTestGinContext("GET", "/api/auth/coderabbit/status", nil) + // Don't set auth header + httpUtils.SetUserContext("test-user", "Test User", "test@example.com") + + GetCodeRabbitStatus(context) + + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + httpUtils.AssertErrorMessage("Invalid or missing token") + }) + + It("Should require user authentication", func() { + context := httpUtils.CreateTestGinContext("GET", "/api/auth/coderabbit/status", nil) + // Don't set auth header or user context + + GetCodeRabbitStatus(context) + + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + httpUtils.AssertJSONContains(map[string]interface{}{ + "error": "Invalid or missing token", + }) + }) + + It("Should return connected:false when no credentials stored", func() { + context := httpUtils.CreateTestGinContext("GET", "/api/auth/coderabbit/status", nil) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetUserContext("test-user", "Test User", "test@example.com") + + GetCodeRabbitStatus(context) + + httpUtils.AssertHTTPStatus(http.StatusOK) + httpUtils.AssertJSONContains(map[string]interface{}{ + "connected": false, + }) + }) + + It("Should handle invalid user ID type", func() { + context := httpUtils.CreateTestGinContext("GET", "/api/auth/coderabbit/status", nil) + httpUtils.SetAuthHeader(testToken) + context.Set("userID", 123) // Invalid type + + GetCodeRabbitStatus(context) + + // GetString returns empty string for non-string types, triggers auth required error + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + httpUtils.AssertErrorMessage("User authentication required") + }) + }) + + Describe("DisconnectCodeRabbit", func() { + It("Should require authentication", func() { + context := httpUtils.CreateTestGinContext("DELETE", "/api/auth/coderabbit/disconnect", nil) + // Don't set auth header + httpUtils.SetUserContext("test-user", "Test User", "test@example.com") + + DisconnectCodeRabbit(context) + + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + httpUtils.AssertErrorMessage("Invalid or missing token") + }) + + It("Should require user authentication", func() { + context := httpUtils.CreateTestGinContext("DELETE", "/api/auth/coderabbit/disconnect", nil) + // Don't set auth header or user context + + DisconnectCodeRabbit(context) + + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + httpUtils.AssertJSONContains(map[string]interface{}{ + "error": "Invalid or missing token", + }) + }) + + It("Should succeed idempotently when no credentials exist", func() { + context := httpUtils.CreateTestGinContext("DELETE", "/api/auth/coderabbit/disconnect", nil) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetUserContext("test-user", "Test User", "test@example.com") + + DisconnectCodeRabbit(context) + + httpUtils.AssertHTTPStatus(http.StatusOK) + httpUtils.AssertJSONContains(map[string]interface{}{ + "message": "CodeRabbit disconnected successfully", + }) + }) + }) + }) + + Context("Data Structure Validation", func() { + Describe("Request and Response Types", func() { + It("Should validate CodeRabbitCredentials structure", func() { + creds := CodeRabbitCredentials{ + UserID: "user123", + APIKey: "cr_test_key_1234567890", + UpdatedAt: metav1.Now().Time, + } + + Expect(creds.UserID).To(Equal("user123")) + Expect(creds.APIKey).To(Equal("cr_test_key_1234567890")) + Expect(creds.UpdatedAt).NotTo(BeZero()) + }) + }) + }) +}) diff --git a/components/backend/handlers/integration_validation.go b/components/backend/handlers/integration_validation.go index deb4f9e57..e137aebbb 100755 --- a/components/backend/handlers/integration_validation.go +++ b/components/backend/handlers/integration_validation.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log" "net/http" "net/url" "time" @@ -227,3 +228,29 @@ func TestGitLabConnection(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"valid": true, "message": "GitLab connection successful"}) } + +// 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 { + log.Printf("CodeRabbit API key validation failed: %v", err) + c.JSON(http.StatusOK, gin.H{"valid": false, "error": "Failed to validate API key with CodeRabbit"}) + 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"}) +} diff --git a/components/backend/handlers/integrations_status.go b/components/backend/handlers/integrations_status.go index 36e992c59..327f021d6 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) + // CodeRabbit status + response["coderabbit"] = getCodeRabbitStatusForUser(ctx, userID) + // MCP server credentials status response["mcpServers"] = getMCPServerStatusForUser(ctx, userID) @@ -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..475cab477 100755 --- a/components/backend/handlers/runtime_credentials.go +++ b/components/backend/handlers/runtime_credentials.go @@ -343,6 +343,43 @@ func GetGitLabTokenForSession(c *gin.Context) { }) } +// 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 + } + + effectiveUserID, ok := enforceCredentialRBAC(c, reqK8s, reqDyn, project, session) + if !ok { + return + } + + creds, err := GetCodeRabbitCredentials(c.Request.Context(), effectiveUserID) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "CodeRabbit credentials not configured"}) + return + } + log.Printf("Failed to get CodeRabbit credentials for user %s: %v", effectiveUserID, 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, + }) +} + // refreshGoogleAccessToken refreshes a Google OAuth access token using the refresh token func refreshGoogleAccessToken(ctx context.Context, oldCreds *GoogleOAuthCredentials) (*GoogleOAuthCredentials, error) { if oldCreds.RefreshToken == "" { diff --git a/components/backend/routes.go b/components/backend/routes.go index 23abad9de..be84d589c 100644 --- a/components/backend/routes.go +++ b/components/backend/routes.go @@ -99,6 +99,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/coderabbit", handlers.GetCodeRabbitCredentialsForSession) projectGroup.GET("/agentic-sessions/:sessionName/credentials/mcp/:serverName", handlers.GetMCPCredentialsForSession) // Session export @@ -179,6 +180,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/integrations-panel.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/settings/integrations-panel.tsx index 0ce6ae906..6a61c78fe 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 @@ -18,6 +18,7 @@ export function IntegrationsPanel() { const githubConfigured = integrationsStatus?.github?.active != null; const gitlabConfigured = integrationsStatus?.gitlab?.connected ?? false; const jiraConfigured = integrationsStatus?.jira?.connected ?? false; + const coderabbitConfigured = integrationsStatus?.coderabbit?.connected ?? false; const googleConfigured = integrationsStatus?.google?.connected ?? false; const integrations = [ @@ -48,6 +49,15 @@ export function IntegrationsPanel() { configured: jiraConfigured, configuredMessage: "Authenticated. Issue and project access enabled.", }, + { + key: "coderabbit", + name: "CodeRabbit", + configured: true, + configuredMessage: + coderabbitConfigured + ? "Active. API key configured for private repos." + : "Active for public repositories. No configuration needed.", + }, ].sort((a, b) => a.name.localeCompare(b.name)); const configuredCount = integrations.filter((i) => i.configured).length; @@ -132,4 +142,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..82b660732 --- /dev/null +++ b/components/frontend/src/components/coderabbit-connection-card.tsx @@ -0,0 +1,238 @@ +'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, ChevronDown, ChevronRight, TriangleAlert } 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 [showAdvanced, setShowAdvanced] = useState(status?.connected ?? false) + const [showForm, setShowForm] = useState(false) + const [apiKey, setApiKey] = useState('') + const [showKey, setShowKey] = useState(false) + + const handleConnect = async () => { + if (!apiKey) { + toast.error('Please enter an API key') + return + } + + connectMutation.mutate( + { apiKey }, + { + onSuccess: () => { + toast.success('API key saved for private repository access') + setShowForm(false) + setApiKey('') + onRefresh?.() + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : 'Failed to save API key') + }, + } + ) + } + + const handleDisconnect = async () => { + disconnectMutation.mutate(undefined, { + onSuccess: () => { + toast.success('API key removed') + onRefresh?.() + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : 'Failed to remove API key') + }, + }) + } + + return ( + +
+ {/* Header */} +
+
+ +
+
+

CodeRabbit

+

AI-powered code review

+
+
+ + {/* Default status — public repos are free */} +
+
+ + + Active for public repositories + +
+

+ Code review is free for public repositories via the{' '} + + CodeRabbit GitHub App + + . No configuration needed. +

+
+ + {/* Private repo access — collapsed by default */} +
+ + + {showAdvanced && ( +
+ {/* Billing warning */} +
+ +

+ Only needed for private repositories. Using an API key on public repos will incur + charges for reviews that are otherwise free. +

+
+ + {status?.connected ? ( + <> +

+ API key configured for private repository reviews. +

+
+ + +
+ + ) : ( +

+ Add an API key to enable CLI reviews for private repositories in sessions. +

+ )} + + {/* API key form */} + {(showForm || (!status?.connected && showAdvanced)) && ( +
+
+ +
+ setApiKey(e.target.value)} + disabled={connectMutation.isPending} + /> + +
+

+ Log in with GitHub at{' '} + + CodeRabbit API Keys + + {' '}to generate a key. +

+
+
+ + {status?.connected && ( + + )} +
+
+ )} +
+ )} +
+
+
+ ) +} 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..c7bbfa5cf --- /dev/null +++ b/components/frontend/src/services/api/coderabbit-auth.ts @@ -0,0 +1,38 @@ +import { apiClient } from './client' + +export type CodeRabbitStatus = { + connected: boolean + updatedAt?: string +} + +export type CodeRabbitConnectRequest = { + apiKey: string +} + +/** + * Get CodeRabbit connection status for the authenticated user + */ +export async function getCodeRabbitStatus(): Promise { + return apiClient.get('/auth/coderabbit/status') +} + +/** + * Connect CodeRabbit account for the authenticated user + */ +export async function connectCodeRabbit(data: CodeRabbitConnectRequest): Promise { + await apiClient.post('/auth/coderabbit/connect', data) +} + +/** + * Disconnect CodeRabbit account for the authenticated user + */ +export async function disconnectCodeRabbit(): Promise { + await apiClient.delete('/auth/coderabbit/disconnect') +} + +/** + * Test CodeRabbit API key validity + */ +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..8b1d07b4b 100644 --- a/components/frontend/src/services/api/integrations.ts +++ b/components/frontend/src/services/api/integrations.ts @@ -34,6 +34,11 @@ export type IntegrationsStatus = { updatedAt?: string valid?: boolean } + coderabbit: { + connected: boolean + updatedAt?: string + valid?: boolean + } mcpServers?: Record } 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..2459dd987 --- /dev/null +++ b/components/frontend/src/services/queries/use-coderabbit.ts @@ -0,0 +1,33 @@ +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'] }) + queryClient.invalidateQueries({ queryKey: ['integrations', 'status'] }) + }, + }) +} + +export function useDisconnectCodeRabbit() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: codeRabbitAuthApi.disconnectCodeRabbit, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['coderabbit', 'status'] }) + queryClient.invalidateQueries({ queryKey: ['integrations', 'status'] }) + }, + }) +} diff --git a/components/runners/ambient-runner/ambient_runner/platform/auth.py b/components/runners/ambient-runner/ambient_runner/platform/auth.py index e29dda48f..1e0393cb5 100755 --- a/components/runners/ambient-runner/ambient_runner/platform/auth.py +++ b/components/runners/ambient-runner/ambient_runner/platform/auth.py @@ -284,6 +284,17 @@ 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. + + Returns dict with: apiKey + """ + 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: @@ -306,11 +317,18 @@ async def populate_runtime_credentials(context: RunnerContext) -> None: logger.info("Fetching fresh credentials from backend API...") # Fetch all credentials concurrently - google_creds, jira_creds, gitlab_creds, github_creds = await asyncio.gather( + ( + google_creds, + jira_creds, + gitlab_creds, + github_creds, + coderabbit_creds, + ) = await asyncio.gather( fetch_google_credentials(context), fetch_jira_credentials(context), fetch_gitlab_credentials(context), fetch_github_credentials(context), + fetch_coderabbit_credentials(context), return_exceptions=True, ) @@ -404,6 +422,15 @@ async def populate_runtime_credentials(context: RunnerContext) -> None: if github_creds.get("email"): git_user_email = github_creds["email"] + # CodeRabbit credentials + if isinstance(coderabbit_creds, Exception): + logger.warning(f"Failed to refresh CodeRabbit credentials: {coderabbit_creds}") + if isinstance(coderabbit_creds, PermissionError): + auth_failures.append(str(coderabbit_creds)) + elif coderabbit_creds.get("apiKey"): + os.environ["CODERABBIT_API_KEY"] = coderabbit_creds["apiKey"] + logger.info("Updated CodeRabbit API key in environment") + # Configure git identity, credential helper, and gh CLI wrapper await configure_git_identity(git_user_name, git_user_email) install_git_credential_helper() @@ -432,6 +459,7 @@ def clear_runtime_credentials() -> None: "JIRA_URL", "JIRA_EMAIL", "USER_GOOGLE_EMAIL", + "CODERABBIT_API_KEY", ]: if os.environ.pop(key, None) is not None: cleared.append(key) diff --git a/docs/pr-1307-impact-analysis.md b/docs/pr-1307-impact-analysis.md new file mode 100644 index 000000000..79388f59d --- /dev/null +++ b/docs/pr-1307-impact-analysis.md @@ -0,0 +1,34 @@ +# PR #1307 Impact Analysis: CodeRabbit Integration as Reference Implementation + +This document demonstrates the impact of PR #1307 (Claude Code automation overhaul) by showing how each artifact it introduced shaped the CodeRabbit integration implementation. + +## Impact Summary + +| #1307 Artifact | What It Provides | How It Shaped CodeRabbit Output | +|---|---|---| +| **scaffold/SKILL.md** | Integration file structure template + post-scaffold checklist | Defined the exact 10 new + 10 modified file list. Every file path in the plan came from this template. Without it, we'd have been reverse-engineering the pattern from existing code. | +| **backend/DEVELOPMENT.md** | Go handler conventions (K8s client selection, error handling, pre-commit checklist) | `ConnectCodeRabbit` uses `GetK8sClientsForRequest` for auth, `K8sClient` for Secret writes. The `/simplify` review caught a signature mismatch because this doc establishes the `func(context.Context, ...) (bool, error)` convention. | +| **backend/K8S_CLIENT_PATTERNS.md** | Decision tree: user-scoped vs service account clients | `GetCodeRabbitCredentialsForSession` uses `enforceCredentialRBAC` (user-scoped) then reads credentials via service account — exactly the pattern this doc prescribes. | +| **backend/ERROR_PATTERNS.md** | HTTP status code conventions (404 vs 400 vs 502) | After the signature fix, `ConnectCodeRabbit` now returns 502 on network errors vs 400 on invalid keys — a distinction this doc requires. | +| **frontend/DEVELOPMENT.md** | Zero-tolerance rules (no `any`, Shadcn only, React Query for all data) | Connection card uses only `Card`, `Button`, `Input`, `Label` from `@/components/ui/*`. API client is pure functions in `services/api/`, hooks in `services/queries/`. No manual `fetch()` in components. | +| **frontend/REACT_QUERY_PATTERNS.md** | Query/mutation hook structure, cache invalidation | `useConnectCodeRabbit` and `useDisconnectCodeRabbit` invalidate both `['coderabbit', 'status']` and `['integrations', 'status']` on success — the dual-invalidation pattern from this doc. | +| **docs/security-standards.md** | Token handling, RBAC enforcement, input validation | API keys never logged (only `len(token)` pattern initially, then simplified). `validateCodeRabbitAPIKeyImpl` uses `networkError()` to strip URLs from error messages — preventing credential leakage. | +| **settings.json hooks** | Real-time enforcement during edits (Shadcn reminder, React Query reminder, K8s client reminder, no-panic reminder) | These hooks fired during subagent implementation, keeping each subagent on-pattern even without full codebase context. The Shadcn hook ensures no raw `