2026-06-19 22:10:13 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 23:24:06 +08:00
|
|
|
// 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 }
|
2026-06-19 22:10:13 +08:00
|
|
|
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
|
|
|
|
|
}
|