2026-06-18 22:48:16 +08:00
package handlers
import (
"database/sql"
2026-06-19 19:43:18 +08:00
"encoding/json"
"fmt"
2026-06-18 22:48:16 +08:00
"net/http"
2026-06-19 19:43:18 +08:00
"strconv"
2026-06-18 22:48:16 +08:00
2026-06-19 22:10:13 +08:00
"github.com/HoHD/PR-Helper/models"
2026-06-19 19:43:18 +08:00
"github.com/HoHD/PR-Helper/services"
2026-06-18 22:48:16 +08:00
"github.com/gin-gonic/gin"
)
type ReviewHandler struct {
db * sql . DB
}
func NewReviewHandler ( db * sql . DB ) * ReviewHandler {
return & ReviewHandler { db : db }
}
2026-06-19 19:43:18 +08:00
// Review handles POST /api/repos/:id/review — SSE streaming AI code review.
2026-06-18 22:48:16 +08:00
func ( h * ReviewHandler ) Review ( c * gin . Context ) {
2026-06-19 19:43:18 +08:00
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
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
resultData := map [ string ] interface {}{
"base" : req . Base ,
"head" : req . Head ,
"top_n" : topN ,
}
resultJSON , _ := json . Marshal ( resultData )
2026-06-19 22:10:13 +08:00
result , err := h . db . Exec ( `INSERT INTO analyses (repo_id, type, base_ref, head_ref, result) VALUES (?, 'code_review', ?, ?, ?)` ,
2026-06-19 19:43:18 +08:00
id , req . Base , req . Head , string ( resultJSON ))
2026-06-19 22:10:13 +08:00
if err == nil {
analysisID , _ := result . LastInsertId ()
sendEvent ( "analysis_saved" , map [ string ] interface {}{
"analysis_id" : analysisID ,
})
}
2026-06-18 22:48:16 +08:00
}
2026-06-19 22:10:13 +08:00
// SaveNotes handles POST /api/repos/:id/review/notes — upsert a review note.
2026-06-18 22:48:16 +08:00
func ( h * ReviewHandler ) SaveNotes ( c * gin . Context ) {
2026-06-19 22:10:13 +08:00
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 )
2026-06-18 22:48:16 +08:00
}
2026-06-19 22:10:13 +08:00
// GetNotes handles GET /api/repos/:id/review/notes — list review notes for an analysis.
2026-06-18 22:48:16 +08:00
func ( h * ReviewHandler ) GetNotes ( c * gin . Context ) {
2026-06-19 22:10:13 +08:00
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 )
2026-06-18 22:48:16 +08:00
}
2026-06-19 22:10:13 +08:00
// GeneratePDF handles POST /api/repos/:id/review/pdf — generate and download a PDF report.
2026-06-18 22:48:16 +08:00
func ( h * ReviewHandler ) GeneratePDF ( c * gin . Context ) {
2026-06-19 22:10:13 +08:00
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 )
2026-06-18 22:48:16 +08:00
}