feat: Phase 5 — 审查备注编辑器与 PDF 导出
This commit is contained in:
@@ -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
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user