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-20 21:57:46 +08:00
user := GetCurrentUser ( c )
if user == nil {
c . JSON ( http . StatusUnauthorized , gin . H { "error" : "unauthenticated" })
return
}
2026-06-19 19:43:18 +08:00
id := c . Param ( "id" )
2026-06-20 21:57:46 +08:00
// Get repo info (scoped to user)
2026-06-19 19:43:18 +08:00
var localPath string
2026-06-20 21:57:46 +08:00
err := h . db . QueryRow ( `SELECT local_path FROM repositories WHERE id = ? AND user_id = ?` , id , user . ID ). Scan ( & localPath )
2026-06-19 19:43:18 +08:00
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 {
2026-06-19 23:40:13 +08:00
Base string `json:"base" binding:"required"`
Head string `json:"head" binding:"required"`
TopN * int `json:"top_n"`
Concurrency * int `json:"concurrency"`
2026-06-19 19:43:18 +08:00
}
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
2026-06-20 21:57:46 +08:00
h . db . QueryRow ( `SELECT value FROM user_settings WHERE user_id = ? AND key = 'review.top_n'` , user . ID ). Scan ( & topNStr )
2026-06-19 19:43:18 +08:00
if topNStr != "" {
if n , err := strconv . Atoi ( topNStr ); err == nil && n > 0 {
topN = n
}
}
}
2026-06-19 23:40:13 +08:00
// Determine concurrency: request value > settings default (5)
concurrency := 5
if req . Concurrency != nil {
concurrency = * req . Concurrency
} else {
var concStr string
2026-06-20 21:57:46 +08:00
h . db . QueryRow ( `SELECT value FROM user_settings WHERE user_id = ? AND key = 'review.concurrency'` , user . ID ). Scan ( & concStr )
2026-06-19 23:40:13 +08:00
if concStr != "" {
if n , err := strconv . Atoi ( concStr ); err == nil && n > 0 {
concurrency = n
}
}
}
2026-06-19 19:43:18 +08:00
// 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 {}) {
2026-06-20 00:06:00 +08:00
jsonData , err := json . Marshal ( data )
if err != nil {
jsonData = [] byte ( `{"error":"failed to marshal event data"}` )
}
2026-06-19 19:43:18 +08:00
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 )
2026-06-20 21:57:46 +08:00
// 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 )
2026-06-19 19:43:18 +08:00
if err != nil {
sendEvent ( "error" , map [ string ] interface {}{ "message" : err . Error ()})
return
}
2026-06-20 21:57:46 +08:00
// Save analysis to DB with user_id
2026-06-20 00:06:00 +08:00
resultJSON , err := json . Marshal ( reviewResult )
if err != nil {
sendEvent ( "error" , map [ string ] interface {}{ "message" : "marshal result: " + err . Error ()})
return
}
2026-06-20 21:57:46 +08:00
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 ))
2026-06-20 00:06:00 +08:00
if err != nil {
sendEvent ( "error" , map [ string ] interface {}{ "message" : "save analysis: " + err . Error ()})
return
2026-06-19 22:10:13 +08:00
}
2026-06-20 00:06:00 +08:00
analysisID , err := res . LastInsertId ()
if err != nil {
sendEvent ( "error" , map [ string ] interface {}{ "message" : "get analysis id: " + err . Error ()})
return
}
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-20 21:57:46 +08:00
user := GetCurrentUser ( c )
if user == nil {
c . JSON ( http . StatusUnauthorized , gin . H { "error" : "unauthenticated" })
return
}
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
}
2026-06-20 21:57:46 +08:00
// 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" : "analysis not found" })
return
}
if err != nil {
c . JSON ( http . StatusInternalServerError , gin . H { "error" : err . Error ()})
return
}
2026-06-19 22:10:13 +08:00
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-20 21:57:46 +08:00
user := GetCurrentUser ( c )
if user == nil {
c . JSON ( http . StatusUnauthorized , gin . H { "error" : "unauthenticated" })
return
}
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
}
2026-06-20 21:57:46 +08:00
// 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" : "analysis not found" })
return
}
if err != nil {
c . JSON ( http . StatusInternalServerError , gin . H { "error" : err . Error ()})
return
}
2026-06-19 22:10:13 +08:00
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 23:24:06 +08:00
// ListReviews handles GET /api/repos/:id/review/analyses — list past code review analyses.
func ( h * ReviewHandler ) ListReviews ( c * gin . Context ) {
2026-06-20 21:57:46 +08:00
user := GetCurrentUser ( c )
if user == nil {
c . JSON ( http . StatusUnauthorized , gin . H { "error" : "unauthenticated" })
return
}
2026-06-19 23:24:06 +08:00
id := c . Param ( "id" )
2026-06-20 21:57:46 +08:00
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 )
2026-06-19 23:24:06 +08:00
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 ,
})
}
2026-06-20 00:06:00 +08:00
if err := rows . Err (); err != nil {
c . JSON ( http . StatusInternalServerError , gin . H { "error" : err . Error ()})
return
}
2026-06-19 23:24:06 +08:00
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 ) {
2026-06-20 21:57:46 +08:00
user := GetCurrentUser ( c )
if user == nil {
c . JSON ( http . StatusUnauthorized , gin . H { "error" : "unauthenticated" })
return
}
2026-06-19 23:24:06 +08:00
aid := c . Param ( "aid" )
var result , baseRef , headRef , createdAt string
2026-06-20 21:57:46 +08:00
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 )
2026-06-19 23:24:06 +08:00
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 ,
})
}