diff --git a/handlers/review.go b/handlers/review.go index 8dc671a..60aab41 100644 --- a/handlers/review.go +++ b/handlers/review.go @@ -84,23 +84,18 @@ func (h *ReviewHandler) Review(c *gin.Context) { 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) + 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 - resultData := map[string]interface{}{ - "base": req.Base, - "head": req.Head, - "top_n": topN, - } - resultJSON, _ := json.Marshal(resultData) - result, err := h.db.Exec(`INSERT INTO analyses (repo_id, type, base_ref, head_ref, result) VALUES (?, 'code_review', ?, ?, ?)`, + // 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, _ := result.LastInsertId() + analysisID, _ := res.LastInsertId() sendEvent("analysis_saved", map[string]interface{}{ "analysis_id": analysisID, }) @@ -166,6 +161,69 @@ func (h *ReviewHandler) GetNotes(c *gin.Context) { 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") diff --git a/main.go b/main.go index de77d6f..5de4557 100644 --- a/main.go +++ b/main.go @@ -89,6 +89,8 @@ func main() { r.GET("/api/repos/:id/diff", repos.GetDiff) r.POST("/api/repos/:id/generate", generate.Generate) r.POST("/api/repos/:id/review", review.Review) + r.GET("/api/repos/:id/review/analyses", review.ListReviews) + r.GET("/api/repos/:id/review/analyses/:aid", review.GetReview) r.POST("/api/repos/:id/review/notes", review.SaveNotes) r.GET("/api/repos/:id/review/notes", review.GetNotes) r.POST("/api/repos/:id/review/pdf", review.GeneratePDF) diff --git a/services/pdf.go b/services/pdf.go index c5149c7..a0a062c 100644 --- a/services/pdf.go +++ b/services/pdf.go @@ -63,7 +63,39 @@ func parseReportData(data ReportData) ParsedReview { noteMap[key] = append(noteMap[key], n.Content) } - // Try to parse structured analysis result + // Try to parse structured analysis result (new ReviewResult format) + var reviewResult ReviewResult + if err := json.Unmarshal([]byte(data.Result), &reviewResult); err == nil && len(reviewResult.FileReviews) > 0 { + // New format: { file_reviews: [...], summary: {...}, top_n: N } + result.Score = reviewResult.Summary.Score + result.Overall = reviewResult.Summary.Overall + result.Findings = reviewResult.Summary.Findings + result.Recommendations = reviewResult.Summary.Recommendations + + for _, fr := range reviewResult.FileReviews { + fileReport := FileReviewForReport{ + FileName: fr.FileName, + ChangeLines: fr.ChangeLines, + } + // Collect file-level notes + fileReport.Notes = noteMap["file:"+fr.FileName] + + for _, s := range fr.Suggestions { + sug := SuggestionForReport{ + Severity: s.Severity, + SeverityCN: severityCN(s.Severity), + Description: s.Description, + Suggestion: s.Suggestion, + CodeExample: s.CodeExample, + } + fileReport.Suggestions = append(fileReport.Suggestions, sug) + } + result.FileReviews = append(result.FileReviews, fileReport) + } + return result + } + + // Fallback: try legacy flat format { score, overall, findings, recommendations } var analysisMap map[string]interface{} if err := json.Unmarshal([]byte(data.Result), &analysisMap); err == nil { if score, ok := analysisMap["score"].(float64); ok { diff --git a/services/review.go b/services/review.go index 1120fee..6558b65 100644 --- a/services/review.go +++ b/services/review.go @@ -34,6 +34,13 @@ type ReviewSummary struct { Recommendations string `json:"recommendations"` } +// ReviewResult holds the complete review output for persistence. +type ReviewResult struct { + FileReviews []FileReview `json:"file_reviews"` + Summary ReviewSummary `json:"summary"` + TopN int `json:"top_n"` +} + // countDiffLines counts the number of added/removed lines in a diff patch. func countDiffLines(patch string) int { count := 0 @@ -48,24 +55,25 @@ func countDiffLines(patch string) int { } // GenerateReview performs AI code review on diff files with Top-N strategy. -// It streams events (file_start, suggestion, file_end, summary, done) via callback. -func GenerateReview(db *sql.DB, repoPath, base, head string, topN int, callback StreamCallback) error { +// It streams events (file_start, suggestion, file_end, summary, done) via callback +// and returns the complete ReviewResult for persistence. +func GenerateReview(db *sql.DB, repoPath, base, head string, topN int, callback StreamCallback) (*ReviewResult, error) { // Read LLM config config, err := GetLLMConfig(db) if err != nil { - return err + return nil, err } // Open repo repo, err := OpenRepo(repoPath) if err != nil { - return fmt.Errorf("open repo: %w", err) + return nil, fmt.Errorf("open repo: %w", err) } // Get diff files files, err := GetDiffFiles(repo, base, head) if err != nil { - return fmt.Errorf("get diff files: %w", err) + return nil, fmt.Errorf("get diff files: %w", err) } if len(files) == 0 { @@ -73,7 +81,7 @@ func GenerateReview(db *sql.DB, repoPath, base, head string, topN int, callback callback("summary", map[string]interface{}{"content": "没有检测到代码变更。"}) callback("done", map[string]interface{}{"content": ""}) } - return nil + return &ReviewResult{TopN: topN}, nil } // Sort by change size (descending) @@ -226,6 +234,7 @@ func GenerateReview(db *sql.DB, repoPath, base, head string, topN int, callback } // Generate summary + var summary ReviewSummary if len(fileReviews) > 0 { if callback != nil { callback("progress", map[string]interface{}{"step": "generating_summary"}) @@ -265,7 +274,6 @@ func GenerateReview(db *sql.DB, repoPath, base, head string, topN int, callback summaryResponse, err := ChatStream(config, messages, callback) if err == nil { jsonStr := extractJSON(summaryResponse) - var summary ReviewSummary if json.Unmarshal([]byte(jsonStr), &summary) == nil { summaryText := fmt.Sprintf("整体评分: %d/10\n\n%s", summary.Score, summary.Overall) if summary.Findings != "" { @@ -294,5 +302,9 @@ func GenerateReview(db *sql.DB, repoPath, base, head string, topN int, callback callback("done", map[string]interface{}{"content": ""}) } - return nil + return &ReviewResult{ + FileReviews: fileReviews, + Summary: summary, + TopN: topN, + }, nil } diff --git a/static/js/diff-viewer.js b/static/js/diff-viewer.js index dfffba5..5a3f4fe 100644 --- a/static/js/diff-viewer.js +++ b/static/js/diff-viewer.js @@ -780,6 +780,94 @@ const DiffViewer = { `; row.parentNode.insertBefore(suggestionRow, row.nextSibling); + }, + + // ── Insert file-level suggestion (no line number required) ───── + + insertFileSuggestion(filename, severity, content, suggestionId) { + const diffContent = this.container.querySelector('#diff-content'); + if (!diffContent) return; + + // Find or index the file + const fileEntry = this._ensureFileIndexed(filename); + if (!fileEntry) return; + + const wrapper = fileEntry.wrapper; + + // Expand the file if collapsed + const fileContent = wrapper.querySelector('.d2h-file-diff'); + if (fileContent && fileContent.style.display === 'none') { + fileContent.style.display = 'block'; + wrapper.classList.remove('d2h-collapsed'); + } + + // Find or create the file-level suggestions container + let sugContainer = wrapper.querySelector('.file-suggestions-container'); + if (!sugContainer) { + sugContainer = document.createElement('div'); + sugContainer.className = 'file-suggestions-container'; + // Insert after the file header, before the diff content + const header = wrapper.querySelector('.d2h-file-header'); + if (header) { + header.parentNode.insertBefore(sugContainer, header.nextSibling); + } else { + wrapper.prepend(sugContainer); + } + } + + const severityStyles = { + critical: 'border-l-4 border-red-500 bg-red-50', + warning: 'border-l-4 border-yellow-500 bg-yellow-50', + info: 'border-l-4 border-green-500 bg-green-50', + }; + const severityLabels = { + critical: '🔴 严重', + warning: '🟡 建议', + info: '🟢 提示', + }; + + const card = document.createElement('div'); + card.className = `review-file-suggestion ${severityStyles[severity] || severityStyles.info} p-3 mx-2 my-1 rounded`; + card.setAttribute('data-suggestion-id', suggestionId || ''); + card.innerHTML = ` +