package handlers import ( "database/sql" "encoding/json" "fmt" "net/http" "strconv" "github.com/HoHD/PR-Helper/models" "github.com/HoHD/PR-Helper/services" "github.com/gin-gonic/gin" ) type ReviewHandler struct { db *sql.DB } func NewReviewHandler(db *sql.DB) *ReviewHandler { return &ReviewHandler{db: db} } // Review handles POST /api/repos/:id/review — SSE streaming AI code review. func (h *ReviewHandler) Review(c *gin.Context) { user := GetCurrentUser(c) if user == nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"}) return } id := c.Param("id") // Get repo info (scoped to user) var localPath string err := h.db.QueryRow(`SELECT local_path FROM repositories WHERE id = ? AND user_id = ?`, id, user.ID).Scan(&localPath) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "仓库未找到"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Parse request var req struct { Base string `json:"base" binding:"required"` Head string `json:"head" binding:"required"` TopN *int `json:"top_n"` Concurrency *int `json:"concurrency"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "请选择 Base 和 Head 分支"}) return } // Determine Top-N: request value > settings default (20) topN := 20 if req.TopN != nil { topN = *req.TopN } else { var topNStr string h.db.QueryRow("SELECT value FROM user_settings WHERE user_id = ? AND `key` = 'review.top_n'", user.ID).Scan(&topNStr) if topNStr != "" { if n, err := strconv.Atoi(topNStr); err == nil && n > 0 { topN = n } } } // Determine concurrency: request value > settings default (5) concurrency := 5 if req.Concurrency != nil { concurrency = *req.Concurrency } else { var concStr string h.db.QueryRow("SELECT value FROM user_settings WHERE user_id = ? AND `key` = 'review.concurrency'", user.ID).Scan(&concStr) if concStr != "" { if n, err := strconv.Atoi(concStr); err == nil && n > 0 { concurrency = n } } } // Set SSE headers c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") c.Header("X-Accel-Buffering", "no") c.Status(http.StatusOK) flusher, ok := c.Writer.(http.Flusher) if !ok { c.JSON(http.StatusInternalServerError, gin.H{"error": "服务器不支持流式传输"}) return } sendEvent := func(event string, data interface{}) { jsonData, err := json.Marshal(data) if err != nil { jsonData = []byte(`{"error":"序列化事件数据失败"}`) } fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, jsonData) flusher.Flush() } // Update last_used h.db.Exec(`UPDATE repositories SET last_used = NOW() WHERE id = ?`, id) // Run AI review (pass user ID for per-user LLM config) reviewResult, err := services.GenerateReview(h.db, localPath, req.Base, req.Head, topN, concurrency, user.ID, sendEvent) if err != nil { sendEvent("error", map[string]interface{}{"message": err.Error()}) return } // Save analysis to DB with user_id resultJSON, err := json.Marshal(reviewResult) if err != nil { sendEvent("error", map[string]interface{}{"message": "序列化结果失败: " + err.Error()}) return } res, err := h.db.Exec(`INSERT INTO analyses (user_id, repo_id, type, base_ref, head_ref, result) VALUES (?, ?, 'code_review', ?, ?, ?)`, user.ID, id, req.Base, req.Head, string(resultJSON)) if err != nil { sendEvent("error", map[string]interface{}{"message": "保存分析结果失败: " + err.Error()}) return } analysisID, err := res.LastInsertId() if err != nil { sendEvent("error", map[string]interface{}{"message": "获取分析记录失败: " + err.Error()}) return } sendEvent("analysis_saved", map[string]interface{}{ "analysis_id": analysisID, }) } // SaveNotes handles POST /api/repos/:id/review/notes — upsert a review note. func (h *ReviewHandler) SaveNotes(c *gin.Context) { user := GetCurrentUser(c) if user == nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"}) return } var req struct { AnalysisID int64 `json:"analysis_id" binding:"required"` Scope string `json:"scope" binding:"required"` ScopeKey string `json:"scope_key"` Content string `json:"content"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "缺少必要参数"}) return } // Validate scope switch req.Scope { case "overall", "file", "suggestion": // valid default: c.JSON(http.StatusBadRequest, gin.H{"error": "scope 参数无效"}) return } // Verify analysis belongs to user var analysisOwnerID int64 err := h.db.QueryRow(`SELECT user_id FROM analyses WHERE id = ?`, req.AnalysisID).Scan(&analysisOwnerID) if err == sql.ErrNoRows || analysisOwnerID != user.ID { c.JSON(http.StatusNotFound, gin.H{"error": "分析记录未找到"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } note, err := services.SaveNote(h.db, req.AnalysisID, req.Scope, req.ScopeKey, req.Content) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, note) } // GetNotes handles GET /api/repos/:id/review/notes — list review notes for an analysis. func (h *ReviewHandler) GetNotes(c *gin.Context) { user := GetCurrentUser(c) if user == nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"}) return } analysisIDStr := c.Query("analysis_id") if analysisIDStr == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 analysis_id 参数"}) return } analysisID, err := strconv.ParseInt(analysisIDStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "analysis_id 无效"}) return } // Verify analysis belongs to user var analysisOwnerID int64 err = h.db.QueryRow(`SELECT user_id FROM analyses WHERE id = ?`, analysisID).Scan(&analysisOwnerID) if err == sql.ErrNoRows || analysisOwnerID != user.ID { c.JSON(http.StatusNotFound, gin.H{"error": "分析记录未找到"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } scope := c.Query("scope") // optional filter notes, err := services.GetNotes(h.db, analysisID, scope) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if notes == nil { notes = []models.ReviewNote{} } c.JSON(http.StatusOK, notes) } // ListReviews handles GET /api/repos/:id/review/analyses — list past code review analyses. func (h *ReviewHandler) ListReviews(c *gin.Context) { user := GetCurrentUser(c) if user == nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"}) return } id := c.Param("id") rows, err := h.db.Query(`SELECT id, base_ref, head_ref, result, created_at FROM analyses WHERE repo_id = ? AND user_id = ? AND type = 'code_review' ORDER BY created_at DESC`, id, user.ID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } defer rows.Close() var analyses []map[string]interface{} for rows.Next() { var aid int64 var baseRef, headRef, result string var createdAt string if err := rows.Scan(&aid, &baseRef, &headRef, &result, &createdAt); err != nil { continue } analyses = append(analyses, map[string]interface{}{ "id": aid, "base_ref": baseRef, "head_ref": headRef, "created_at": createdAt, }) } if err := rows.Err(); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if analyses == nil { analyses = []map[string]interface{}{} } c.JSON(http.StatusOK, analyses) } // GetReview handles GET /api/repos/:id/review/analyses/:aid — get a single review with full result. func (h *ReviewHandler) GetReview(c *gin.Context) { user := GetCurrentUser(c) if user == nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"}) return } aid := c.Param("aid") var result, baseRef, headRef, createdAt string err := h.db.QueryRow(`SELECT result, base_ref, head_ref, created_at FROM analyses WHERE id = ? AND user_id = ? AND type = 'code_review'`, aid, user.ID).Scan(&result, &baseRef, &headRef, &createdAt) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "分析记录未找到"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Parse the result JSON to include structured data var reviewResult interface{} if err := json.Unmarshal([]byte(result), &reviewResult); err != nil { reviewResult = result } c.JSON(http.StatusOK, gin.H{ "id": aid, "base_ref": baseRef, "head_ref": headRef, "created_at": createdAt, "result": reviewResult, }) }