From 9e804820cbc9d3cb7015043aa1e920d92c1d9c5e Mon Sep 17 00:00:00 2001 From: v0agent Date: Tue, 2 Jun 2026 17:06:06 +0000 Subject: [PATCH 01/24] feat: add new auth and cache modules for user registration and login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Huỳnh Thương <252359928+Huynhthuongg@users.noreply.github.com> --- gitbot/backend/auth.go | 240 +++++++++++++++ gitbot/backend/cache.go | 140 +++++++++ gitbot/backend/database.go | 73 +++++ gitbot/backend/email.go | 184 ++++++++++++ gitbot/backend/go.mod | 5 + gitbot/backend/main.go | 299 +++++++++++++++++-- gitbot/backend/webhook.go | 299 +++++++++++++++++++ gitbot/docker-compose.yml | 22 +- gitbot/frontend/app/auth/page.tsx | 170 +++++++++++ gitbot/frontend/app/globals.css | 42 ++- gitbot/frontend/app/layout.tsx | 11 +- gitbot/frontend/app/page.tsx | 157 ++++++---- gitbot/frontend/components/ApprovalPanel.tsx | 102 +++++++ gitbot/frontend/components/DiffViewer.tsx | 138 +++++++++ gitbot/frontend/components/FileFilter.tsx | 108 +++++++ gitbot/frontend/components/Header.tsx | 56 ++++ gitbot/frontend/components/Sidebar.tsx | 85 ++++++ gitbot/frontend/components/Stats.tsx | 63 ++++ gitbot/frontend/package.json | 3 +- 19 files changed, 2109 insertions(+), 88 deletions(-) create mode 100644 gitbot/backend/auth.go create mode 100644 gitbot/backend/cache.go create mode 100644 gitbot/backend/database.go create mode 100644 gitbot/backend/email.go create mode 100644 gitbot/backend/webhook.go create mode 100644 gitbot/frontend/app/auth/page.tsx create mode 100644 gitbot/frontend/components/ApprovalPanel.tsx create mode 100644 gitbot/frontend/components/DiffViewer.tsx create mode 100644 gitbot/frontend/components/FileFilter.tsx create mode 100644 gitbot/frontend/components/Header.tsx create mode 100644 gitbot/frontend/components/Sidebar.tsx create mode 100644 gitbot/frontend/components/Stats.tsx diff --git a/gitbot/backend/auth.go b/gitbot/backend/auth.go new file mode 100644 index 0000000..eae230f --- /dev/null +++ b/gitbot/backend/auth.go @@ -0,0 +1,240 @@ +package main + +import ( + "database/sql" + "encoding/json" + "log" + "net/http" + "time" + + "golang.org/x/crypto/bcrypt" +) + +type User struct { + ID string `json:"id"` + Email string `json:"email"` + Username string `json:"username"` + CreatedAt time.Time `json:"created_at"` +} + +type LoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type RegisterRequest struct { + Email string `json:"email"` + Username string `json:"username"` + Password string `json:"password"` +} + +type AuthResponse struct { + User User `json:"user"` + Token string `json:"token"` +} + +type ErrorResponse struct { + Error string `json:"error"` +} + +var jwtSecret = "your-secret-key-change-in-production" + +func generateID() string { + return "id_" + time.Now().Format("20060102150405") + "_" + randomString(12) +} + +func randomString(length int) string { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, length) + for i := range b { + b[i] = charset[(time.Now().UnixNano()+int64(i))%int64(len(charset))] + } + return string(b) +} + +func hashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(hash), err +} + +func checkPassword(hash, password string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +func generateToken(userID string) string { + return "jwt_token_" + userID + "_" + time.Now().Format("20060102150405") +} + +func handleRegister(w http.ResponseWriter, r *http.Request, db *sql.DB) { + if r.Method == "OPTIONS" { + enableCORS(w) + w.WriteHeader(http.StatusOK) + return + } + + enableCORS(w) + + if r.Method != "POST" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req RegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(ErrorResponse{Error: "Invalid request"}) + return + } + + if req.Email == "" || req.Password == "" || req.Username == "" { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(ErrorResponse{Error: "Email, username, and password are required"}) + return + } + + hashedPassword, err := hashPassword(req.Password) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(ErrorResponse{Error: "Failed to hash password"}) + return + } + + userID := generateID() + now := time.Now() + + _, err = db.Exec( + "INSERT INTO users (id, email, username, password_hash, created_at) VALUES ($1, $2, $3, $4, $5)", + userID, req.Email, req.Username, hashedPassword, now, + ) + + if err != nil { + log.Printf("Database error: %v\n", err) + w.WriteHeader(http.StatusConflict) + json.NewEncoder(w).Encode(ErrorResponse{Error: "User already exists"}) + return + } + + user := User{ + ID: userID, + Email: req.Email, + Username: req.Username, + CreatedAt: now, + } + + token := generateToken(userID) + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(AuthResponse{ + User: user, + Token: token, + }) +} + +func handleLogin(w http.ResponseWriter, r *http.Request, db *sql.DB) { + if r.Method == "OPTIONS" { + enableCORS(w) + w.WriteHeader(http.StatusOK) + return + } + + enableCORS(w) + + if r.Method != "POST" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req LoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(ErrorResponse{Error: "Invalid request"}) + return + } + + var user User + var passwordHash string + + err := db.QueryRow( + "SELECT id, email, username, password_hash, created_at FROM users WHERE email = $1", + req.Email, + ).Scan(&user.ID, &user.Email, &user.Username, &passwordHash, &user.CreatedAt) + + if err == sql.ErrNoRows { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(ErrorResponse{Error: "Invalid credentials"}) + return + } else if err != nil { + log.Printf("Database error: %v\n", err) + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(ErrorResponse{Error: "Database error"}) + return + } + + if !checkPassword(passwordHash, req.Password) { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(ErrorResponse{Error: "Invalid credentials"}) + return + } + + token := generateToken(user.ID) + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(AuthResponse{ + User: user, + Token: token, + }) +} + +func handleGetMe(w http.ResponseWriter, r *http.Request, db *sql.DB) { + if r.Method == "OPTIONS" { + enableCORS(w) + w.WriteHeader(http.StatusOK) + return + } + + enableCORS(w) + + token := r.Header.Get("Authorization") + if token == "" { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(ErrorResponse{Error: "Missing token"}) + return + } + + if len(token) > 7 && token[:7] == "Bearer " { + token = token[7:] + } + + userID := extractUserIDFromToken(token) + if userID == "" { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(ErrorResponse{Error: "Invalid token"}) + return + } + + var user User + err := db.QueryRow( + "SELECT id, email, username, created_at FROM users WHERE id = $1", + userID, + ).Scan(&user.ID, &user.Email, &user.Username, &user.CreatedAt) + + if err != nil { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(ErrorResponse{Error: "User not found"}) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(user) +} + +func extractUserIDFromToken(token string) string { + if len(token) > 10 { + parts := len(token) + if parts > 10 { + return token[10 : parts-14] + } + } + return "" +} diff --git a/gitbot/backend/cache.go b/gitbot/backend/cache.go new file mode 100644 index 0000000..c7cd1f9 --- /dev/null +++ b/gitbot/backend/cache.go @@ -0,0 +1,140 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "time" +) + +type CacheStore struct { + data map[string]cacheEntry +} + +type cacheEntry struct { + value interface{} + expiresAt time.Time +} + +var cache = &CacheStore{ + data: make(map[string]cacheEntry), +} + +// In production, use redis.NewClient() from github.com/redis/go-redis/v9 +// For now, using in-memory cache with TTL + +func (c *CacheStore) Set(key string, value interface{}, ttl time.Duration) error { + c.data[key] = cacheEntry{ + value: value, + expiresAt: time.Now().Add(ttl), + } + log.Printf("Cache SET: %s (TTL: %v)\n", key, ttl) + return nil +} + +func (c *CacheStore) Get(key string) (interface{}, bool) { + entry, exists := c.data[key] + if !exists { + return nil, false + } + + if time.Now().After(entry.expiresAt) { + delete(c.data, key) + return nil, false + } + + log.Printf("Cache HIT: %s\n", key) + return entry.value, true +} + +func (c *CacheStore) Delete(key string) error { + delete(c.data, key) + log.Printf("Cache DEL: %s\n", key) + return nil +} + +func (c *CacheStore) Invalidate(pattern string) { + for key := range c.data { + if matchPattern(key, pattern) { + delete(c.data, key) + } + } + log.Printf("Cache invalidated for pattern: %s\n", pattern) +} + +func matchPattern(key, pattern string) bool { + if pattern == "*" { + return true + } + if len(pattern) > 0 && pattern[len(pattern)-1] == '*' { + return len(key) >= len(pattern)-1 && key[:len(pattern)-1] == pattern[:len(pattern)-1] + } + return key == pattern +} + +// Convenience functions for caching diff data +func cacheKey(prefix string, id string) string { + return fmt.Sprintf("%s:%s", prefix, id) +} + +func CacheDiff(prID string, diff []FileDiff) error { + data, err := json.Marshal(diff) + if err != nil { + return err + } + return cache.Set(cacheKey("diff", prID), data, 30*time.Minute) +} + +func GetCachedDiff(prID string) ([]FileDiff, bool) { + data, exists := cache.Get(cacheKey("diff", prID)) + if !exists { + return nil, false + } + + jsonData, ok := data.([]byte) + if !ok { + return nil, false + } + + var diff []FileDiff + if err := json.Unmarshal(jsonData, &diff); err != nil { + return nil, false + } + + return diff, true +} + +func CacheStats(prID string, stats PRStats) error { + data, err := json.Marshal(stats) + if err != nil { + return err + } + return cache.Set(cacheKey("stats", prID), data, 15*time.Minute) +} + +func GetCachedStats(prID string) (PRStats, bool) { + data, exists := cache.Get(cacheKey("stats", prID)) + if !exists { + return PRStats{}, false + } + + jsonData, ok := data.([]byte) + if !ok { + return PRStats{}, false + } + + var stats PRStats + if err := json.Unmarshal(jsonData, &stats); err != nil { + return PRStats{}, false + } + + return stats, true +} + +func InvalidateUserCache(userID string) { + cache.Invalidate(fmt.Sprintf("user:%s:*", userID)) +} + +func InvalidatePRCache(prID string) { + cache.Invalidate(fmt.Sprintf("*:%s", prID)) +} diff --git a/gitbot/backend/database.go b/gitbot/backend/database.go new file mode 100644 index 0000000..467e4ca --- /dev/null +++ b/gitbot/backend/database.go @@ -0,0 +1,73 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + + _ "github.com/lib/pq" +) + +func initDatabase() (*sql.DB, error) { + connStr := "postgres://gitbot_admin:SecretPassword123@postgres:5432/gitbot_db?sslmode=disable" + + db, err := sql.Open("postgres", connStr) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + if err = db.Ping(); err != nil { + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + log.Println("Database connected successfully") + + if err := createTables(db); err != nil { + return nil, fmt.Errorf("failed to create tables: %w", err) + } + + return db, nil +} + +func createTables(db *sql.DB) error { + schema := ` + CREATE TABLE IF NOT EXISTS users ( + id VARCHAR(36) PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + username VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS reviews ( + id VARCHAR(36) PRIMARY KEY, + user_id VARCHAR(36) NOT NULL REFERENCES users(id), + pr_number INTEGER NOT NULL, + status VARCHAR(50) NOT NULL, + feedback TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS comments ( + id VARCHAR(36) PRIMARY KEY, + user_id VARCHAR(36) NOT NULL REFERENCES users(id), + file_path VARCHAR(255) NOT NULL, + line_num INTEGER NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_reviews_user_id ON reviews(user_id); + CREATE INDEX IF NOT EXISTS idx_reviews_pr_number ON reviews(pr_number); + CREATE INDEX IF NOT EXISTS idx_comments_user_id ON comments(user_id); + CREATE INDEX IF NOT EXISTS idx_comments_file_path ON comments(file_path); + ` + + _, err := db.Exec(schema) + if err != nil { + return fmt.Errorf("failed to execute schema: %w", err) + } + + log.Println("Database tables created successfully") + return nil +} diff --git a/gitbot/backend/email.go b/gitbot/backend/email.go new file mode 100644 index 0000000..a1039af --- /dev/null +++ b/gitbot/backend/email.go @@ -0,0 +1,184 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "time" +) + +type EmailNotification struct { + To string + Subject string + Body string + HTMLBody string + Timestamp time.Time +} + +type EmailRequest struct { + Email string `json:"email"` + Subject string `json:"subject"` + Body string `json:"body"` +} + +// In production, use SendGrid, AWS SES, or similar email service +// For now, logging to console and storing in database + +var emailQueue []EmailNotification + +func SendEmail(to, subject, body string) error { + notification := EmailNotification{ + To: to, + Subject: subject, + Body: body, + HTMLBody: generateHTMLEmail(subject, body), + Timestamp: time.Now(), + } + + emailQueue = append(emailQueue, notification) + log.Printf("Email queued for %s: %s\n", to, subject) + + // In production, implement actual email sending here + // Example with SendGrid: + // from := "noreply@gitbot.io" + // m := mail.NewV3Mail() + // m.SetFrom(mail.NewEmail("GitBot", from)) + // m.Subject = subject + // p := mail.NewPersonalization() + // p.AddTos(mail.NewEmail("", to)) + // p.SetDynamicTemplateData(data) + // m.AddPersonalizations(p) + + return nil +} + +func generateHTMLEmail(subject, body string) string { + return fmt.Sprintf(` + + +
+
+

%s

+
+
+ %s +
+
+

This is an automated email from GitBot. Please do not reply to this email.

+

Copyright © 2026 GitBot. All rights reserved.

+
+
+ + + `, subject, body) +} + +func handleSendEmail(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + enableCORS(w) + w.WriteHeader(http.StatusOK) + return + } + + enableCORS(w) + + if r.Method != "POST" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req EmailRequest + if err := r.ParseForm(); err == nil { + req.Email = r.FormValue("email") + req.Subject = r.FormValue("subject") + req.Body = r.FormValue("body") + } else { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + } + + if req.Email == "" || req.Subject == "" || req.Body == "" { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Email, subject, and body are required", + }) + return + } + + if err := SendEmail(req.Email, req.Subject, req.Body); err != nil { + log.Printf("Error sending email: %v\n", err) + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to send email", + }) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{ + "success": "true", + "message": "Email sent successfully", + }) +} + +// Send notification emails based on events +func SendApprovalNotification(email, username, prTitle string, approved bool) error { + subject := fmt.Sprintf("PR Review: %s", prTitle) + status := "approved" + if !approved { + status = "requested changes" + } + + body := fmt.Sprintf(` +

Your PR has been %s

+

Hi %s,

+

Your pull request "%s" has been %s.

+

View Review

+

Thank you for using GitBot!

+ `, status, username, prTitle, status) + + return SendEmail(email, subject, body) +} + +func SendCommentNotification(email, username, fileName string) error { + subject := fmt.Sprintf("New comment on %s", fileName) + body := fmt.Sprintf(` +

You have a new comment

+

Hi %s,

+

%s commented on %s.

+

View Comment

+ `, username, username, fileName) + + return SendEmail(email, subject, body) +} + +func SendWelcomeEmail(email, username string) error { + subject := "Welcome to GitBot" + body := fmt.Sprintf(` +

Welcome to GitBot

+

Hi %s,

+

Welcome to GitBot, your AI-powered code review assistant!

+

You can now:

+ +

Get Started

+

Happy reviewing!

+ `, username) + + return SendEmail(email, subject, body) +} + +func GetEmailQueue() []EmailNotification { + return emailQueue +} + +func ClearEmailQueue() { + emailQueue = []EmailNotification{} +} diff --git a/gitbot/backend/go.mod b/gitbot/backend/go.mod index 5d7e65f..814e849 100644 --- a/gitbot/backend/go.mod +++ b/gitbot/backend/go.mod @@ -1,3 +1,8 @@ module gitbot-backend go 1.24 + +require ( + github.com/lib/pq v1.10.9 + golang.org/x/crypto v0.19.0 +) diff --git a/gitbot/backend/main.go b/gitbot/backend/main.go index f838bbb..57aaf0e 100644 --- a/gitbot/backend/main.go +++ b/gitbot/backend/main.go @@ -3,13 +3,16 @@ package main import ( "encoding/json" "fmt" + "log" "net/http" + "sync" + "time" ) -// Cấu trúc dữ liệu trả về cho màn hình Diff (Mobile/Desktop đều dùng chung) +// Data structures type DiffLine struct { - Type string `json:"type"` // "addition", "deletion", "neutral" - Content string `json:"content"` // Nội dung dòng code + Type string `json:"type"` + Content string `json:"content"` LineNum int `json:"line_num"` } @@ -18,29 +21,277 @@ type FileDiff struct { Lines []DiffLine `json:"lines"` } -func main() { - // API lấy danh sách code thay đổi (Diff) của Pull Request - http.HandleFunc("/api/v1/diff", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Content-Type", "application/json") - - // Dữ liệu mẫu giả lập từ Git Core Engine trả về - mockDiff := []FileDiff{ - { - FilePath: "components/Auth.ts", - Lines: []DiffLine{ - {Type: "neutral", Content: "package auth", LineNum: 1}, - {Type: "deletion", Content: "- func Login(u string) {", LineNum: 2}, - {Type: "addition", Content: "+ func Login(email string, pass string) {", LineNum: 3}, - {Type: "addition", Content: "+ \t// AI Bot: Đã check bảo mật SQL Injection ở đây", LineNum: 4}, - {Type: "neutral", Content: "\treturn true", LineNum: 5}, - }, - }, +type Comment struct { + ID string `json:"id"` + LineNum int `json:"line_num"` + FilePath string `json:"file_path"` + Author string `json:"author"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` +} + +type PRStats struct { + FilesChanged int `json:"files_changed"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + Commits int `json:"commits"` +} + +type ApprovalRequest struct { + PRNumber int `json:"pr_number"` + Status string `json:"status"` + Feedback string `json:"feedback"` +} + +// In-memory storage +var ( + comments []Comment + commentsMutex sync.RWMutex + approvalStatus map[int]string = make(map[int]string) +) + +// Mock data +var mockDiff = []FileDiff{ + { + FilePath: "components/Auth.tsx", + Lines: []DiffLine{ + {Type: "neutral", Content: "import React from 'react';", LineNum: 1}, + {Type: "deletion", Content: "- export const Login = () => {", LineNum: 2}, + {Type: "addition", Content: "+ export const Login: React.FC = ({ onSuccess }) => {", LineNum: 3}, + {Type: "neutral", Content: " const [email, setEmail] = React.useState('');", LineNum: 4}, + {Type: "addition", Content: "+ const [password, setPassword] = React.useState('');", LineNum: 5}, + {Type: "neutral", Content: " return (", LineNum: 6}, + }, + }, + { + FilePath: "hooks/useAuth.ts", + Lines: []DiffLine{ + {Type: "neutral", Content: "export function useAuth() {", LineNum: 1}, + {Type: "deletion", Content: "- const [user, setUser] = useState(null);", LineNum: 2}, + {Type: "addition", Content: "+ const [user, setUser] = useState(null);", LineNum: 3}, + {Type: "addition", Content: "+ const [isLoading, setIsLoading] = useState(false);", LineNum: 4}, + {Type: "neutral", Content: " return { user, login, logout };", LineNum: 5}, + }, + }, + { + FilePath: "utils/validation.ts", + Lines: []DiffLine{ + {Type: "neutral", Content: "export const validateEmail = (email: string) => {", LineNum: 1}, + {Type: "addition", Content: "+ const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;", LineNum: 2}, + {Type: "addition", Content: "+ return emailRegex.test(email);", LineNum: 3}, + {Type: "neutral", Content: "};", LineNum: 4}, + }, + }, +} + +func enableCORS(w http.ResponseWriter) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + w.Header().Set("Content-Type", "application/json") +} + +// GET /api/v1/diff +func handleGetDiff(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + enableCORS(w) + w.WriteHeader(http.StatusOK) + return + } + + enableCORS(w) + + prID := r.URL.Query().Get("pr_id") + if prID == "" { + prID = "default" + } + + // Try to get from cache + if cachedDiff, ok := GetCachedDiff(prID); ok { + json.NewEncoder(w).Encode(cachedDiff) + return + } + + // Cache the diff + CacheDiff(prID, mockDiff) + json.NewEncoder(w).Encode(mockDiff) +} + +// GET /api/v1/stats +func handleGetStats(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + enableCORS(w) + w.WriteHeader(http.StatusOK) + return + } + + enableCORS(w) + + prID := r.URL.Query().Get("pr_id") + if prID == "" { + prID = "default" + } + + // Try to get from cache + if cachedStats, ok := GetCachedStats(prID); ok { + json.NewEncoder(w).Encode(cachedStats) + return + } + + stats := PRStats{ + FilesChanged: len(mockDiff), + Additions: 0, + Deletions: 0, + Commits: 3, + } + + for _, file := range mockDiff { + for _, line := range file.Lines { + if line.Type == "addition" { + stats.Additions++ + } else if line.Type == "deletion" { + stats.Deletions++ + } + } + } + + // Cache the stats + CacheStats(prID, stats) + json.NewEncoder(w).Encode(stats) +} + +// GET/POST /api/v1/comments +func handleComments(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + enableCORS(w) + w.WriteHeader(http.StatusOK) + return + } + + enableCORS(w) + + if r.Method == "GET" { + commentsMutex.RLock() + defer commentsMutex.RUnlock() + + if len(comments) == 0 { + json.NewEncoder(w).Encode([]Comment{}) + return + } + json.NewEncoder(w).Encode(comments) + } else if r.Method == "POST" { + var comment Comment + if err := json.NewDecoder(r.Body).Decode(&comment); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return } + + comment.ID = fmt.Sprintf("comment_%d", time.Now().UnixNano()) + comment.CreatedAt = time.Now() + + commentsMutex.Lock() + comments = append(comments, comment) + commentsMutex.Unlock() + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(comment) + } +} + +// POST /api/v1/approve +func handleApprove(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + enableCORS(w) + w.WriteHeader(http.StatusOK) + return + } + + enableCORS(w) + + if r.Method != "POST" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req ApprovalRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + approvalStatus[req.PRNumber] = req.Status + + response := map[string]interface{}{ + "success": true, + "message": fmt.Sprintf("PR #%d marked as %s", req.PRNumber, req.Status), + "status": req.Status, + } + + json.NewEncoder(w).Encode(response) +} + +// GET /api/v1/files +func handleGetFiles(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + enableCORS(w) + w.WriteHeader(http.StatusOK) + return + } + + enableCORS(w) + + files := make([]string, len(mockDiff)) + for i, file := range mockDiff { + files[i] = file.FilePath + } + + json.NewEncoder(w).Encode(files) +} + +// GET /health +func handleHealth(w http.ResponseWriter, r *http.Request) { + enableCORS(w) + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} - json.NewEncoder(w).Encode(mockDiff) +func main() { + // Initialize database + db, err := initDatabase() + if err != nil { + log.Fatalf("Failed to initialize database: %v\n", err) + } + defer db.Close() + + // API routes + http.HandleFunc("/api/v1/diff", handleGetDiff) + http.HandleFunc("/api/v1/stats", handleGetStats) + http.HandleFunc("/api/v1/comments", handleComments) + http.HandleFunc("/api/v1/approve", handleApprove) + http.HandleFunc("/api/v1/files", handleGetFiles) + http.HandleFunc("/health", handleHealth) + + // Auth routes + http.HandleFunc("/api/v1/auth/register", func(w http.ResponseWriter, r *http.Request) { + handleRegister(w, r, db) + }) + http.HandleFunc("/api/v1/auth/login", func(w http.ResponseWriter, r *http.Request) { + handleLogin(w, r, db) }) + http.HandleFunc("/api/v1/auth/me", func(w http.ResponseWriter, r *http.Request) { + handleGetMe(w, r, db) + }) + + // Email routes + http.HandleFunc("/api/v1/email/send", handleSendEmail) + + // Webhook routes + http.HandleFunc("/api/v1/webhooks/config", handleWebhookConfig) + http.HandleFunc("/api/v1/webhooks/broadcast", handleBroadcastEvent) + + port := ":8080" + log.Printf("GitBot Backend starting on %s\n", port) - fmt.Println("🚀 GitBot Backend đang chạy tại cổng :8080") - http.ListenAndServe(":8080", nil) + if err := http.ListenAndServe(port, nil); err != nil { + log.Fatalf("Server error: %v\n", err) + } } diff --git a/gitbot/backend/webhook.go b/gitbot/backend/webhook.go new file mode 100644 index 0000000..da665fc --- /dev/null +++ b/gitbot/backend/webhook.go @@ -0,0 +1,299 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" +) + +type WebhookEvent struct { + Type string `json:"type"` + Title string `json:"title"` + Message string `json:"message"` + User string `json:"user"` + Timestamp string `json:"timestamp"` + Extra interface{} `json:"extra,omitempty"` +} + +type SlackMessage struct { + Text string `json:"text"` + Attachments []Attachment `json:"attachments,omitempty"` +} + +type Attachment struct { + Color string `json:"color"` + Title string `json:"title"` + Text string `json:"text"` + Fields []Field `json:"fields,omitempty"` +} + +type Field struct { + Title string `json:"title"` + Value string `json:"value"` + Short bool `json:"short"` +} + +type DiscordMessage struct { + Username string `json:"username,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + Content string `json:"content"` + Embeds []DiscordEmbed `json:"embeds,omitempty"` +} + +type DiscordEmbed struct { + Title string `json:"title"` + Description string `json:"description"` + Color int `json:"color"` + Fields []DiscordField `json:"fields,omitempty"` + Footer DiscordEmbedFooter `json:"footer,omitempty"` +} + +type DiscordField struct { + Name string `json:"name"` + Value string `json:"value"` + Inline bool `json:"inline"` +} + +type DiscordEmbedFooter struct { + Text string `json:"text"` +} + +type WebhookConfig struct { + SlackWebhookURL string + DiscordWebhookURL string +} + +var webhookConfig = WebhookConfig{ + SlackWebhookURL: "", // Set from environment or config + DiscordWebhookURL: "", // Set from environment or config +} + +func SendSlackNotification(event WebhookEvent) error { + if webhookConfig.SlackWebhookURL == "" { + log.Println("Slack webhook URL not configured") + return nil + } + + color := "#3b82f6" // Blue + if strings.Contains(event.Type, "approved") { + color = "#10b981" // Green + } else if strings.Contains(event.Type, "rejected") { + color = "#ef4444" // Red + } + + message := SlackMessage{ + Text: fmt.Sprintf("*%s*", event.Title), + Attachments: []Attachment{ + { + Color: color, + Title: event.Title, + Text: event.Message, + Fields: []Field{ + { + Title: "User", + Value: event.User, + Short: true, + }, + { + Title: "Event Type", + Value: event.Type, + Short: true, + }, + { + Title: "Time", + Value: event.Timestamp, + Short: true, + }, + }, + }, + }, + } + + payload, err := json.Marshal(message) + if err != nil { + return fmt.Errorf("failed to marshal Slack message: %w", err) + } + + resp, err := http.Post(webhookConfig.SlackWebhookURL, "application/json", bytes.NewBuffer(payload)) + if err != nil { + return fmt.Errorf("failed to send Slack notification: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("Slack API returned status %d: %s", resp.StatusCode, string(body)) + } + + log.Printf("Slack notification sent: %s\n", event.Title) + return nil +} + +func SendDiscordNotification(event WebhookEvent) error { + if webhookConfig.DiscordWebhookURL == "" { + log.Println("Discord webhook URL not configured") + return nil + } + + color := 3447003 // Blue + if strings.Contains(event.Type, "approved") { + color = 3066993 // Green + } else if strings.Contains(event.Type, "rejected") { + color = 15158332 // Red + } + + message := DiscordMessage{ + Username: "GitBot", + AvatarURL: "https://api.dicebear.com/7.x/avataaars/svg?seed=GitBot", + Content: fmt.Sprintf("**%s**", event.Title), + Embeds: []DiscordEmbed{ + { + Title: event.Title, + Description: event.Message, + Color: color, + Fields: []DiscordField{ + { + Name: "User", + Value: event.User, + Inline: true, + }, + { + Name: "Event Type", + Value: event.Type, + Inline: true, + }, + { + Name: "Time", + Value: event.Timestamp, + Inline: false, + }, + }, + Footer: DiscordEmbedFooter{ + Text: "GitBot Code Review", + }, + }, + }, + } + + payload, err := json.Marshal(message) + if err != nil { + return fmt.Errorf("failed to marshal Discord message: %w", err) + } + + resp, err := http.Post(webhookConfig.DiscordWebhookURL, "application/json", bytes.NewBuffer(payload)) + if err != nil { + return fmt.Errorf("failed to send Discord notification: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("Discord API returned status %d: %s", resp.StatusCode, string(body)) + } + + log.Printf("Discord notification sent: %s\n", event.Title) + return nil +} + +func BroadcastEvent(event WebhookEvent) error { + // Send to both Slack and Discord + if err := SendSlackNotification(event); err != nil { + log.Printf("Slack notification error: %v\n", err) + } + + if err := SendDiscordNotification(event); err != nil { + log.Printf("Discord notification error: %v\n", err) + } + + return nil +} + +func handleWebhookConfig(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + enableCORS(w) + w.WriteHeader(http.StatusOK) + return + } + + enableCORS(w) + + switch r.Method { + case "GET": + // Return current webhook config (without revealing full URLs) + config := map[string]interface{}{ + "slack_configured": webhookConfig.SlackWebhookURL != "", + "discord_configured": webhookConfig.DiscordWebhookURL != "", + } + json.NewEncoder(w).Encode(config) + + case "POST": + var req map[string]string + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + if slack, ok := req["slack_webhook"]; ok && slack != "" { + webhookConfig.SlackWebhookURL = slack + log.Println("Slack webhook configured") + } + + if discord, ok := req["discord_webhook"]; ok && discord != "" { + webhookConfig.DiscordWebhookURL = discord + log.Println("Discord webhook configured") + } + + json.NewEncoder(w).Encode(map[string]string{ + "success": "true", + "message": "Webhooks configured", + }) + + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +func handleBroadcastEvent(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + enableCORS(w) + w.WriteHeader(http.StatusOK) + return + } + + enableCORS(w) + + if r.Method != "POST" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var event WebhookEvent + if err := json.NewDecoder(r.Body).Decode(&event); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + if event.Title == "" || event.Message == "" { + http.Error(w, "Title and message are required", http.StatusBadRequest) + return + } + + if err := BroadcastEvent(event); err != nil { + log.Printf("Error broadcasting event: %v\n", err) + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to broadcast event", + }) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{ + "success": "true", + "message": "Event broadcasted to Slack and Discord", + }) +} diff --git a/gitbot/docker-compose.yml b/gitbot/docker-compose.yml index a5406a1..8dcc24b 100644 --- a/gitbot/docker-compose.yml +++ b/gitbot/docker-compose.yml @@ -11,6 +11,21 @@ services: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U gitbot_admin"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 backend: image: golang:1.24-alpine @@ -20,9 +35,12 @@ services: working_dir: /app ports: - "8080:8080" - command: sh -c "go mod tidy && go run main.go" + command: sh -c "go mod tidy && go run main.go auth.go database.go cache.go email.go webhook.go" depends_on: - - postgres + postgres: + condition: service_healthy + redis: + condition: service_healthy frontend: image: node:20-alpine diff --git a/gitbot/frontend/app/auth/page.tsx b/gitbot/frontend/app/auth/page.tsx new file mode 100644 index 0000000..d781b9c --- /dev/null +++ b/gitbot/frontend/app/auth/page.tsx @@ -0,0 +1,170 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { LogIn, UserPlus } from 'lucide-react'; + +export default function AuthPage() { + const router = useRouter(); + const [isLogin, setIsLogin] = useState(true); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [username, setUsername] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + const endpoint = isLogin ? '/api/v1/auth/login' : '/api/v1/auth/register'; + const body = isLogin ? { email, password } : { email, password, username }; + + const response = await fetch(`http://localhost:8080${endpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const data = await response.json(); + setError(data.error || 'Authentication failed'); + return; + } + + const data = await response.json(); + localStorage.setItem('token', data.token); + localStorage.setItem('user', JSON.stringify(data.user)); + + router.push('/'); + } catch (err) { + setError('Network error. Please try again.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ {/* Logo */} +
+
+ +
+

GitBot

+

Code Review Assistant

+
+ + {/* Form card */} +
+
+

+ {isLogin ? 'Welcome Back' : 'Create Account'} +

+

+ {isLogin + ? 'Sign in to your GitBot account' + : 'Join GitBot for smarter code reviews'} +

+
+ +
+ {/* Email */} +
+ + setEmail(e.target.value)} + placeholder="your@email.com" + required + className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/50 transition-all" + /> +
+ + {/* Username (register only) */} + {!isLogin && ( +
+ + setUsername(e.target.value)} + placeholder="your_username" + required + className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/50 transition-all" + /> +
+ )} + + {/* Password */} +
+ + setPassword(e.target.value)} + placeholder="••••••••" + required + className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/50 transition-all" + /> +
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Submit button */} + +
+ + {/* Toggle */} +
+

+ {isLogin ? "Don't have an account? " : 'Already have an account? '} + +

+
+
+ + {/* Footer */} +

+ By using GitBot, you agree to our Terms of Service and Privacy Policy +

+
+
+ ); +} diff --git a/gitbot/frontend/app/globals.css b/gitbot/frontend/app/globals.css index 1ef5fed..bfefd7b 100644 --- a/gitbot/frontend/app/globals.css +++ b/gitbot/frontend/app/globals.css @@ -1,5 +1,21 @@ @import 'tailwindcss'; +@theme inline { + --font-sans: 'Inter', 'Inter Fallback', system-ui, -apple-system, sans-serif; + --color-background: #0f172a; + --color-surface: #1e293b; + --color-surface-light: #334155; + --color-border: #475569; + --color-text-primary: #f1f5f9; + --color-text-secondary: #cbd5e1; + --color-accent-blue: #3b82f6; + --color-accent-green: #10b981; + --color-accent-red: #ef4444; + --color-success: #10b981; + --color-error: #ef4444; + --radius: 0.5rem; +} + * { margin: 0; padding: 0; @@ -8,10 +24,30 @@ html { scroll-behavior: smooth; + background-color: var(--color-background); } body { - font-family: system-ui, -apple-system, sans-serif; - background-color: #111827; - color: #f3f4f6; + font-family: var(--font-sans); + background-color: var(--color-background); + color: var(--color-text-primary); +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--color-surface); +} + +::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--color-surface-light); } diff --git a/gitbot/frontend/app/layout.tsx b/gitbot/frontend/app/layout.tsx index 500c688..322da33 100644 --- a/gitbot/frontend/app/layout.tsx +++ b/gitbot/frontend/app/layout.tsx @@ -1,9 +1,12 @@ import type { Metadata } from 'next'; +import { Inter } from 'next/font/google'; import './globals.css'; +const inter = Inter({ subsets: ['latin'], variable: '--font-sans' }); + export const metadata: Metadata = { - title: 'GitBot - AI Code Review Assistant', - description: 'Intelligent code review and diff analysis tool', + title: 'GitBot - Code Review Assistant', + description: 'AI-powered code review and diff viewer for pull requests', }; export default function RootLayout({ @@ -12,8 +15,8 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - - {children} + + {children} ); } diff --git a/gitbot/frontend/app/page.tsx b/gitbot/frontend/app/page.tsx index db879f2..62bb465 100644 --- a/gitbot/frontend/app/page.tsx +++ b/gitbot/frontend/app/page.tsx @@ -1,72 +1,121 @@ 'use client'; + import { useEffect, useState } from 'react'; +import { Sidebar } from '@/components/Sidebar'; +import { Header } from '@/components/Header'; +import { DiffViewer } from '@/components/DiffViewer'; +import { Stats } from '@/components/Stats'; +import { FileFilter } from '@/components/FileFilter'; +import { ApprovalPanel } from '@/components/ApprovalPanel'; + +interface DiffLine { + type: 'addition' | 'deletion' | 'neutral'; + content: string; + line_num: number; +} + +interface FileDiff { + file_path: string; + lines: DiffLine[]; +} export default function GitBotDiffPage() { - const [diffData, setDiffData] = useState([]); + const [diffData, setDiffData] = useState([]); + const [filteredFiles, setFilteredFiles] = useState([]); + const [approvalStatus, setApprovalStatus] = useState<'pending' | 'approved' | 'rejected'>('pending'); + const [isLoading, setIsLoading] = useState(true); useEffect(() => { - // Gọi API từ Backend Go + // Fetch từ Backend Go fetch('http://localhost:8080/api/v1/diff') - .then(res => res.json()) - .then(data => setDiffData(data)) - .catch(err => console.error(err)); + .then((res) => res.json()) + .then((data) => { + setDiffData(data); + setFilteredFiles(data); + setIsLoading(false); + }) + .catch((err) => { + console.error('[v0] Error fetching diff:', err); + setIsLoading(false); + }); }, []); + const handleFilterChange = (filteredFilePaths: string[]) => { + const filtered = diffData.filter((file) => filteredFilePaths.includes(file.file_path)); + setFilteredFiles(filtered); + }; + + const calculateStats = () => { + let additions = 0; + let deletions = 0; + + diffData.forEach((file) => { + file.lines.forEach((line) => { + if (line.type === 'addition') additions++; + if (line.type === 'deletion') deletions++; + }); + }); + + return { + filesChanged: diffData.length, + additions, + deletions, + commits: 3, + }; + }; + + const stats = calculateStats(); + const filePaths = diffData.map((f) => f.file_path); + + // Transform data to match DiffViewer interface + const viewerFiles = filteredFiles.map((file) => ({ + filePath: file.file_path, + lines: file.lines, + additions: file.lines.filter((l) => l.type === 'addition').length, + deletions: file.lines.filter((l) => l.type === 'deletion').length, + })); + return ( -
- {/* HEADER: Responsive từ PC đến Mobile */} -
-
- Open -

PR #124: Tối ưu cơ chế bảo mật Login

-
- -
- - {/* BODY CONTAINER */} -
- {diffData.map((file, fIdx) => ( -
- {/* Thanh tiêu đề file */} -
- 📄 {file.file_path} +
+ +
+ +
+
+ {/* Stats */} + + + {/* Main content grid */} +
+ {/* Filter sidebar */} +
+
- {/* Vùng hiển thị Code Diff - Ép Unified View trên Mobile */} -
-
- {file.lines.map((line: any, lIdx: number) => { - // Định dạng màu sắc dựa vào loại dòng (Thêm/Xóa/Giữ nguyên) - let rowBg = "hover:bg-gray-900"; - let textColor = "text-gray-400"; - if (line.type === "addition") { - rowBg = "bg-green-950/40 hover:bg-green-900/40 border-l-4 border-green-500"; - textColor = "text-green-300"; - } else if (line.type === "deletion") { - rowBg = "bg-red-950/40 hover:bg-red-900/40 border-l-4 border-red-500"; - textColor = "text-red-300"; - } - - return ( -
- {/* Số dòng */} -
- {line.line_num} -
- {/* Nội dung code - word-break chống vỡ khung màn hình điện thoại */} -
- {line.content} -
-
- ); - })} -
+ {/* Diff viewer */} +
+ {isLoading ? ( +
+

Loading diff...

+
+ ) : filteredFiles.length === 0 ? ( +
+

No files match your filters

+
+ ) : ( + + )}
- ))} +
+ + {/* Approval panel */} + setApprovalStatus('approved')} + onRequestChanges={() => setApprovalStatus('rejected')} + />
); } diff --git a/gitbot/frontend/components/ApprovalPanel.tsx b/gitbot/frontend/components/ApprovalPanel.tsx new file mode 100644 index 0000000..0fc9c08 --- /dev/null +++ b/gitbot/frontend/components/ApprovalPanel.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { useState } from 'react'; +import { ThumbsUp, MessageSquare, AlertCircle, CheckCircle } from 'lucide-react'; + +interface ApprovalPanelProps { + onApprove: () => void; + onRequestChanges: () => void; + currentStatus: 'pending' | 'approved' | 'rejected'; +} + +export function ApprovalPanel({ + onApprove, + onRequestChanges, + currentStatus, +}: ApprovalPanelProps) { + const [feedback, setFeedback] = useState(''); + const [showFeedback, setShowFeedback] = useState(false); + + return ( +
+
+ {/* Status display */} +
+
+ {currentStatus === 'approved' ? ( + <> + + Approved + + ) : currentStatus === 'rejected' ? ( + <> + + Changes Requested + + ) : ( + <> + + Awaiting Review + + )} +
+

Last updated: 2 minutes ago

+
+ + {/* Action buttons */} +
+ + + +
+ + {/* Feedback form */} + {showFeedback && ( +
+

Add your feedback

+