feat: Phase 5 — 审查备注编辑器与 PDF 导出

This commit is contained in:
2026-06-19 22:10:13 +08:00
parent 1cfab2d2d1
commit b35d6f874b
10 changed files with 914 additions and 15 deletions
+90
View File
@@ -0,0 +1,90 @@
package services
import (
"database/sql"
"fmt"
"time"
"github.com/HoHD/PR-Helper/models"
)
// SaveNote upserts a review note (insert or update if exists for the same analysis_id + scope + scope_key).
func SaveNote(db *sql.DB, analysisID int64, scope, scopeKey, content string) (*models.ReviewNote, error) {
now := time.Now().UTC()
// Try to find existing note
var existingID int64
err := db.QueryRow(`SELECT id FROM review_notes WHERE analysis_id = ? AND scope = ? AND scope_key = ?`,
analysisID, scope, scopeKey).Scan(&existingID)
if err == sql.ErrNoRows {
// Insert new
result, err := db.Exec(`INSERT INTO review_notes (analysis_id, scope, scope_key, content, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`,
analysisID, scope, scopeKey, content, now, now)
if err != nil {
return nil, fmt.Errorf("insert note: %w", err)
}
id, _ := result.LastInsertId()
return &models.ReviewNote{
ID: id,
AnalysisID: analysisID,
Scope: scope,
ScopeKey: scopeKey,
Content: content,
CreatedAt: now,
UpdatedAt: now,
}, nil
}
if err != nil {
return nil, fmt.Errorf("query existing note: %w", err)
}
// Update existing
_, err = db.Exec(`UPDATE review_notes SET content = ?, updated_at = ? WHERE id = ?`, content, now, existingID)
if err != nil {
return nil, fmt.Errorf("update note: %w", err)
}
return &models.ReviewNote{
ID: existingID,
AnalysisID: analysisID,
Scope: scope,
ScopeKey: scopeKey,
Content: content,
UpdatedAt: now,
}, nil
}
// GetNotes returns all review notes for a given analysis, optionally filtered by scope.
func GetNotes(db *sql.DB, analysisID int64, scope string) ([]models.ReviewNote, error) {
var rows *sql.Rows
var err error
if scope != "" {
rows, err = db.Query(`SELECT id, analysis_id, scope, scope_key, content, created_at, updated_at FROM review_notes WHERE analysis_id = ? AND scope = ? ORDER BY id`,
analysisID, scope)
} else {
rows, err = db.Query(`SELECT id, analysis_id, scope, scope_key, content, created_at, updated_at FROM review_notes WHERE analysis_id = ? ORDER BY id`,
analysisID)
}
if err != nil {
return nil, fmt.Errorf("query notes: %w", err)
}
defer rows.Close()
var notes []models.ReviewNote
for rows.Next() {
var n models.ReviewNote
if err := rows.Scan(&n.ID, &n.AnalysisID, &n.Scope, &n.ScopeKey, &n.Content, &n.CreatedAt, &n.UpdatedAt); err != nil {
continue
}
notes = append(notes, n)
}
return notes, nil
}
// DeleteNote deletes a specific review note by ID.
func DeleteNote(db *sql.DB, noteID int64) error {
_, err := db.Exec(`DELETE FROM review_notes WHERE id = ?`, noteID)
return err
}
+190
View File
@@ -0,0 +1,190 @@
package services
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html/template"
"os"
"time"
"github.com/HoHD/PR-Helper/models"
"github.com/chromedp/chromedp"
"github.com/chromedp/cdproto/page"
)
// ReportData holds all data needed to render the PDF report template.
type ReportData struct {
RepoURL string
BaseRef string
HeadRef string
ReviewedAt string
AnalysisID int64
Result string // raw JSON from analysis
Notes []models.ReviewNote
}
// ParsedReview holds the structured review data for the template.
type ParsedReview struct {
Score int
Overall string
Findings string
Recommendations string
FileReviews []FileReviewForReport
}
// FileReviewForReport is a file review entry formatted for the report template.
type FileReviewForReport struct {
FileName string
ChangeLines int
Suggestions []SuggestionForReport
Notes []string
}
// SuggestionForReport is a single suggestion formatted for the report template.
type SuggestionForReport struct {
Severity string
SeverityCN string
Description string
Suggestion string
CodeExample string
Notes []string
}
// parseReportData converts raw analysis JSON + notes into template-ready structures.
func parseReportData(data ReportData) ParsedReview {
result := ParsedReview{}
// Index notes by scope:scopeKey
noteMap := make(map[string][]string)
for _, n := range data.Notes {
key := n.Scope + ":" + n.ScopeKey
noteMap[key] = append(noteMap[key], n.Content)
}
// Try to parse structured analysis result
var analysisMap map[string]interface{}
if err := json.Unmarshal([]byte(data.Result), &analysisMap); err == nil {
if score, ok := analysisMap["score"].(float64); ok {
result.Score = int(score)
}
if overall, ok := analysisMap["overall"].(string); ok {
result.Overall = overall
}
if findings, ok := analysisMap["findings"].(string); ok {
result.Findings = findings
}
if recs, ok := analysisMap["recommendations"].(string); ok {
result.Recommendations = recs
}
}
return result
}
// severityCN returns the Chinese label for a severity level.
func severityCN(severity string) string {
switch severity {
case "critical":
return "严重"
case "warning":
return "建议"
case "info":
return "提示"
default:
return "提示"
}
}
// GeneratePDFReport generates a PDF from the review report data using chromedp.
func GeneratePDFReport(data ReportData) ([]byte, error) {
review := parseReportData(data)
// Collect overall notes
var overallNotes []string
for _, n := range data.Notes {
if n.Scope == "overall" {
overallNotes = append(overallNotes, n.Content)
}
}
// Build template data
tmplData := struct {
RepoURL string
BaseRef string
HeadRef string
ReviewedAt string
Score int
Overall string
Findings string
Recommendations string
FileReviews []FileReviewForReport
OverallNotes []string
Result string
}{
RepoURL: data.RepoURL,
BaseRef: data.BaseRef,
HeadRef: data.HeadRef,
ReviewedAt: data.ReviewedAt,
Score: review.Score,
Overall: review.Overall,
Findings: review.Findings,
Recommendations: review.Recommendations,
FileReviews: review.FileReviews,
OverallNotes: overallNotes,
Result: data.Result,
}
// Render HTML from template
tmpl, err := template.ParseFiles("templates/reports/review.html")
if err != nil {
return nil, fmt.Errorf("parse template: %w", err)
}
var htmlBuf bytes.Buffer
if err := tmpl.Execute(&htmlBuf, tmplData); err != nil {
return nil, fmt.Errorf("execute template: %w", err)
}
// Write HTML to temp file for chromedp
tmpFile, err := os.CreateTemp("", "pr-helper-report-*.html")
if err != nil {
return nil, fmt.Errorf("create temp file: %w", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.Write(htmlBuf.Bytes()); err != nil {
tmpFile.Close()
return nil, fmt.Errorf("write temp file: %w", err)
}
tmpFile.Close()
// Use chromedp to convert HTML to PDF
ctx, cancel := chromedp.NewContext(context.Background())
defer cancel()
ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
defer cancel()
var pdfBytes []byte
fileURL := "file://" + tmpFile.Name()
err = chromedp.Run(ctx,
chromedp.Navigate(fileURL),
chromedp.WaitReady("body"),
chromedp.ActionFunc(func(ctx context.Context) error {
var err error
pdfBytes, _, err = page.PrintToPDF().
WithDisplayHeaderFooter(false).
WithPrintBackground(true).
Do(ctx)
return err
}),
)
if err != nil {
return nil, fmt.Errorf("chromedp: %w", err)
}
return pdfBytes, nil
}