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) { id := c.Param("id") // Get repo info var localPath string err := h.db.QueryRow(`SELECT local_path FROM repositories WHERE id = ?`, id).Scan(&localPath) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "repository not found"}) 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"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "base and head are required"}) 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 settings WHERE key = 'review.top_n'`).Scan(&topNStr) if topNStr != "" { if n, err := strconv.Atoi(topNStr); err == nil && n > 0 { topN = 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": "streaming not supported"}) return } sendEvent := func(event string, data interface{}) { jsonData, _ := json.Marshal(data) 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 = datetime('now') WHERE id = ?`, id) // Run AI review reviewResult, err := services.GenerateReview(h.db, localPath, req.Base, req.Head, topN, sendEvent) if err != nil { sendEvent("error", map[string]interface{}{"message": err.Error()}) return } // Save analysis to DB with full review result resultJSON, _ := json.Marshal(reviewResult) res, err := h.db.Exec(`INSERT INTO analyses (repo_id, type, base_ref, head_ref, result) VALUES (?, 'code_review', ?, ?, ?)`, id, req.Base, req.Head, string(resultJSON)) if err == nil { analysisID, _ := res.LastInsertId() 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) { 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": "analysis_id and scope are required"}) return } // Validate scope switch req.Scope { case "overall", "file", "suggestion": // valid default: c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be overall, file, or suggestion"}) 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) { analysisIDStr := c.Query("analysis_id") if analysisIDStr == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "analysis_id query parameter is required"}) return } analysisID, err := strconv.ParseInt(analysisIDStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid analysis_id"}) 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) { id := c.Param("id") rows, err := h.db.Query(`SELECT id, base_ref, head_ref, result, created_at FROM analyses WHERE repo_id = ? AND type = 'code_review' ORDER BY created_at DESC`, 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 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) { 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 type = 'code_review'`, aid).Scan(&result, &baseRef, &headRef, &createdAt) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "analysis not found"}) 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, }) } // GeneratePDF handles POST /api/repos/:id/review/pdf — generate and download a PDF report. func (h *ReviewHandler) GeneratePDF(c *gin.Context) { id := c.Param("id") // Get repo info var repoURL string err := h.db.QueryRow(`SELECT url FROM repositories WHERE id = ?`, id).Scan(&repoURL) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "repository not found"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Parse request var req struct { AnalysisID int64 `json:"analysis_id" binding:"required"` Base string `json:"base"` Head string `json:"head"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "analysis_id is required"}) return } // Load the analysis result var analysisResult, baseRef, headRef string var createdAt string err = h.db.QueryRow(`SELECT result, base_ref, head_ref, created_at FROM analyses WHERE id = ?`, req.AnalysisID).Scan(&analysisResult, &baseRef, &headRef, &createdAt) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "analysis not found"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Load all notes for this analysis notes, err := services.GetNotes(h.db, req.AnalysisID, "") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Build report data reportData := services.ReportData{ RepoURL: repoURL, BaseRef: baseRef, HeadRef: headRef, ReviewedAt: createdAt, AnalysisID: req.AnalysisID, Result: analysisResult, Notes: notes, } // Generate PDF pdfBytes, err := services.GeneratePDFReport(reportData) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "PDF generation failed: " + err.Error()}) return } // Return PDF as download filename := fmt.Sprintf("pr-helper-review-%s.pdf", createdAt[:10]) c.Header("Content-Type", "application/pdf") c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) c.Data(http.StatusOK, "application/pdf", pdfBytes) }