Skip to content

Commit 6a50295

Browse files
committed
Merge PR #1145: feat/coderabbit-integration
2 parents 41f69f3 + 736e686 commit 6a50295

File tree

24 files changed

+2278
-18
lines changed

24 files changed

+2278
-18
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,4 @@ hack/
140140

141141
# Personal exports
142142
*.csv
143+
.worktrees/

.pre-commit-config.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,17 @@ repos:
6363
files: ^components/frontend/.*\.(ts|tsx|js|jsx)$
6464
pass_filenames: true
6565

66+
# ── CodeRabbit AI review ───────────────────────────────────────────
67+
- repo: local
68+
hooks:
69+
- id: coderabbit-review
70+
name: coderabbit review
71+
entry: scripts/pre-commit/coderabbit-review.sh
72+
language: script
73+
always_run: true
74+
pass_filenames: false
75+
require_serial: true
76+
6677
# ── Branch protection ────────────────────────────────────────────────
6778
- repo: local
6879
hooks:
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
package handlers
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"log"
8+
"net/http"
9+
"time"
10+
11+
"github.com/gin-gonic/gin"
12+
corev1 "k8s.io/api/core/v1"
13+
"k8s.io/apimachinery/pkg/api/errors"
14+
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
15+
)
16+
17+
// CodeRabbitCredentials represents cluster-level CodeRabbit credentials for a user
18+
type CodeRabbitCredentials struct {
19+
UserID string `json:"userId"`
20+
APIKey string `json:"apiKey"`
21+
UpdatedAt time.Time `json:"updatedAt"`
22+
}
23+
24+
// ConnectCodeRabbit handles POST /api/auth/coderabbit/connect
25+
// Saves user's CodeRabbit credentials at cluster level
26+
func ConnectCodeRabbit(c *gin.Context) {
27+
// Verify user has valid K8s token (follows RBAC pattern)
28+
reqK8s, _ := GetK8sClientsForRequest(c)
29+
if reqK8s == nil {
30+
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"})
31+
return
32+
}
33+
34+
// Verify user is authenticated and userID is valid
35+
userID := c.GetString("userID")
36+
if userID == "" {
37+
c.JSON(http.StatusUnauthorized, gin.H{"error": "User authentication required"})
38+
return
39+
}
40+
if !isValidUserID(userID) {
41+
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user identifier"})
42+
return
43+
}
44+
45+
var req struct {
46+
APIKey string `json:"apiKey" binding:"required"`
47+
}
48+
49+
if err := c.ShouldBindJSON(&req); err != nil {
50+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
51+
return
52+
}
53+
54+
// Validate API key with CodeRabbit
55+
if err := ValidateCodeRabbitAPIKey(c.Request.Context(), req.APIKey); err != nil {
56+
log.Printf("Failed to validate CodeRabbit API key for user %s: %v", userID, err)
57+
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid CodeRabbit API key"})
58+
return
59+
}
60+
61+
// Store credentials
62+
creds := &CodeRabbitCredentials{
63+
UserID: userID,
64+
APIKey: req.APIKey,
65+
UpdatedAt: time.Now(),
66+
}
67+
68+
if err := storeCodeRabbitCredentials(c.Request.Context(), creds); err != nil {
69+
log.Printf("Failed to store CodeRabbit credentials for user %s: %v", userID, err)
70+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save CodeRabbit credentials"})
71+
return
72+
}
73+
74+
log.Printf("✓ Stored CodeRabbit credentials for user %s", userID)
75+
c.JSON(http.StatusOK, gin.H{
76+
"message": "CodeRabbit connected successfully",
77+
})
78+
}
79+
80+
// GetCodeRabbitStatus handles GET /api/auth/coderabbit/status
81+
// Returns connection status for the authenticated user
82+
func GetCodeRabbitStatus(c *gin.Context) {
83+
// Verify user has valid K8s token
84+
reqK8s, _ := GetK8sClientsForRequest(c)
85+
if reqK8s == nil {
86+
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"})
87+
return
88+
}
89+
90+
userID := c.GetString("userID")
91+
if userID == "" {
92+
c.JSON(http.StatusUnauthorized, gin.H{"error": "User authentication required"})
93+
return
94+
}
95+
96+
creds, err := GetCodeRabbitCredentials(c.Request.Context(), userID)
97+
if err != nil {
98+
if errors.IsNotFound(err) {
99+
c.JSON(http.StatusOK, gin.H{"connected": false})
100+
return
101+
}
102+
log.Printf("Failed to get CodeRabbit credentials for user %s: %v", userID, err)
103+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check CodeRabbit status"})
104+
return
105+
}
106+
107+
if creds == nil {
108+
c.JSON(http.StatusOK, gin.H{"connected": false})
109+
return
110+
}
111+
112+
c.JSON(http.StatusOK, gin.H{
113+
"connected": true,
114+
"updatedAt": creds.UpdatedAt.Format(time.RFC3339),
115+
})
116+
}
117+
118+
// DisconnectCodeRabbit handles DELETE /api/auth/coderabbit/disconnect
119+
// Removes user's CodeRabbit credentials
120+
func DisconnectCodeRabbit(c *gin.Context) {
121+
// Verify user has valid K8s token
122+
reqK8s, _ := GetK8sClientsForRequest(c)
123+
if reqK8s == nil {
124+
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"})
125+
return
126+
}
127+
128+
userID := c.GetString("userID")
129+
if userID == "" {
130+
c.JSON(http.StatusUnauthorized, gin.H{"error": "User authentication required"})
131+
return
132+
}
133+
134+
if err := DeleteCodeRabbitCredentials(c.Request.Context(), userID); err != nil {
135+
log.Printf("Failed to delete CodeRabbit credentials for user %s: %v", userID, err)
136+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to disconnect CodeRabbit"})
137+
return
138+
}
139+
140+
log.Printf("✓ Deleted CodeRabbit credentials for user %s", userID)
141+
c.JSON(http.StatusOK, gin.H{"message": "CodeRabbit disconnected successfully"})
142+
}
143+
144+
// storeCodeRabbitCredentials stores CodeRabbit credentials in cluster-level Secret
145+
func storeCodeRabbitCredentials(ctx context.Context, creds *CodeRabbitCredentials) error {
146+
if creds == nil || creds.UserID == "" {
147+
return fmt.Errorf("invalid credentials payload")
148+
}
149+
150+
const secretName = "coderabbit-credentials"
151+
152+
for i := 0; i < 3; i++ { // retry on conflict
153+
secret, err := K8sClient.CoreV1().Secrets(Namespace).Get(ctx, secretName, v1.GetOptions{})
154+
if err != nil {
155+
if errors.IsNotFound(err) {
156+
// Create Secret
157+
secret = &corev1.Secret{
158+
ObjectMeta: v1.ObjectMeta{
159+
Name: secretName,
160+
Namespace: Namespace,
161+
Labels: map[string]string{
162+
"app": "ambient-code",
163+
"ambient-code.io/provider": "coderabbit",
164+
},
165+
},
166+
Type: corev1.SecretTypeOpaque,
167+
Data: map[string][]byte{},
168+
}
169+
if _, cerr := K8sClient.CoreV1().Secrets(Namespace).Create(ctx, secret, v1.CreateOptions{}); cerr != nil && !errors.IsAlreadyExists(cerr) {
170+
return fmt.Errorf("failed to create Secret: %w", cerr)
171+
}
172+
// Fetch again to get resourceVersion
173+
secret, err = K8sClient.CoreV1().Secrets(Namespace).Get(ctx, secretName, v1.GetOptions{})
174+
if err != nil {
175+
return fmt.Errorf("failed to fetch Secret after create: %w", err)
176+
}
177+
} else {
178+
return fmt.Errorf("failed to get Secret: %w", err)
179+
}
180+
}
181+
182+
if secret.Data == nil {
183+
secret.Data = map[string][]byte{}
184+
}
185+
186+
b, err := json.Marshal(creds)
187+
if err != nil {
188+
return fmt.Errorf("failed to marshal credentials: %w", err)
189+
}
190+
secret.Data[creds.UserID] = b
191+
192+
if _, uerr := K8sClient.CoreV1().Secrets(Namespace).Update(ctx, secret, v1.UpdateOptions{}); uerr != nil {
193+
if errors.IsConflict(uerr) {
194+
continue // retry
195+
}
196+
return fmt.Errorf("failed to update Secret: %w", uerr)
197+
}
198+
return nil
199+
}
200+
return fmt.Errorf("failed to update Secret after retries")
201+
}
202+
203+
// GetCodeRabbitCredentials retrieves cluster-level CodeRabbit credentials for a user
204+
func GetCodeRabbitCredentials(ctx context.Context, userID string) (*CodeRabbitCredentials, error) {
205+
if userID == "" {
206+
return nil, fmt.Errorf("userID is required")
207+
}
208+
209+
const secretName = "coderabbit-credentials"
210+
211+
secret, err := K8sClient.CoreV1().Secrets(Namespace).Get(ctx, secretName, v1.GetOptions{})
212+
if err != nil {
213+
return nil, err
214+
}
215+
216+
if secret.Data == nil || len(secret.Data[userID]) == 0 {
217+
return nil, nil // User hasn't connected CodeRabbit
218+
}
219+
220+
var creds CodeRabbitCredentials
221+
if err := json.Unmarshal(secret.Data[userID], &creds); err != nil {
222+
return nil, fmt.Errorf("failed to parse credentials: %w", err)
223+
}
224+
225+
return &creds, nil
226+
}
227+
228+
// DeleteCodeRabbitCredentials removes CodeRabbit credentials for a user
229+
func DeleteCodeRabbitCredentials(ctx context.Context, userID string) error {
230+
if userID == "" {
231+
return fmt.Errorf("userID is required")
232+
}
233+
234+
const secretName = "coderabbit-credentials"
235+
236+
for i := 0; i < 3; i++ { // retry on conflict
237+
secret, err := K8sClient.CoreV1().Secrets(Namespace).Get(ctx, secretName, v1.GetOptions{})
238+
if err != nil {
239+
if errors.IsNotFound(err) {
240+
return nil // Secret doesn't exist, nothing to delete
241+
}
242+
return fmt.Errorf("failed to get Secret: %w", err)
243+
}
244+
245+
if secret.Data == nil || len(secret.Data[userID]) == 0 {
246+
return nil // User's credentials don't exist
247+
}
248+
249+
delete(secret.Data, userID)
250+
251+
if _, uerr := K8sClient.CoreV1().Secrets(Namespace).Update(ctx, secret, v1.UpdateOptions{}); uerr != nil {
252+
if errors.IsConflict(uerr) {
253+
continue // retry
254+
}
255+
return fmt.Errorf("failed to update Secret: %w", uerr)
256+
}
257+
return nil
258+
}
259+
return fmt.Errorf("failed to update Secret after retries")
260+
}

0 commit comments

Comments
 (0)