diff --git a/PLAN.md b/PLAN.md index 1ea09a8..df78ea9 100644 --- a/PLAN.md +++ b/PLAN.md @@ -713,13 +713,13 @@ volumes: ## 10. 开发阶段 -> **当前进度**:Phase 1-4 已完成,Phase 5-6 待开发 +> **当前进度**:Phase 1-5 已完成,Phase 6 待完善 > > - ✅ Phase 1(基础骨架):Go 项目、Gin 路由、SQLite、设置页面 > - ✅ Phase 2(Git 核心):clone、refs、diff、graph 数据、缓存管理 > - ✅ Phase 3(前端交互):Git Graph、Diff 查看器、文件树、SSE 流式展示、行内建议 > - ✅ Phase 4(LLM 集成):OpenAI 兼容 API、PR 描述生成、AI 代码审查、Top-N 策略 -> - ⏳ Phase 5(审查编辑与导出):待开发 +> - ✅ Phase 5(审查编辑与导出):备注编辑器、备注 CRUD API、PDF 报告生成 > - ⚠️ Phase 6(完善与部署):Docker 配置已完成,其他待完善 ### Phase 1:基础骨架 ✅ 已完成 @@ -751,12 +751,12 @@ volumes: - [x] AI 代码审查(分文件处理 + 汇总)(services/review.go) - [x] 大 diff 处理(Top-N 策略) -### Phase 5:审查编辑与导出 ⏳ 待开发 -- [ ] 备注编辑器组件(Markdown 支持,三级作用域) -- [ ] 备注 CRUD API(保存/读取) -- [ ] PDF 报告 HTML 模板(打印样式) -- [ ] chromedp HTML→PDF 生成服务 -- [ ] PDF 下载接口 +### Phase 5:审查编辑与导出 ✅ 已完成 +- [x] 备注编辑器组件(Markdown 支持,三级作用域) +- [x] 备注 CRUD API(保存/读取) +- [x] PDF 报告 HTML 模板(打印样式) +- [x] chromedp HTML→PDF 生成服务 +- [x] PDF 下载接口 ### Phase 6:完善与部署 ⚠️ 部分完成 - [ ] 错误处理和用户提示 diff --git a/go.mod b/go.mod index 0cbf63d..b279545 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module github.com/HoHD/PR-Helper go 1.25.0 require ( + github.com/chromedp/cdproto v0.0.0-20241022234722-4d5d5faf59fb + github.com/chromedp/chromedp v0.11.2 github.com/gin-gonic/gin v1.10.0 github.com/go-git/go-git/v5 v5.19.1 github.com/mattn/go-sqlite3 v1.14.24 @@ -15,6 +17,7 @@ require ( github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/chromedp/sysutil v1.1.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect @@ -27,13 +30,18 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.4.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect diff --git a/go.sum b/go.sum index c6cd173..4fdf6bb 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,12 @@ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/chromedp/cdproto v0.0.0-20241022234722-4d5d5faf59fb h1:noKVm2SsG4v0Yd0lHNtFYc9EUxIVvrr4kJ6hM8wvIYU= +github.com/chromedp/cdproto v0.0.0-20241022234722-4d5d5faf59fb/go.mod h1:4XqMl3iIW08jtieURWL6Tt5924w21pxirC6th662XUM= +github.com/chromedp/chromedp v0.11.2 h1:ZRHTh7DjbNTlfIv3NFTbB7eVeu5XCNkgrpcGSpn2oX0= +github.com/chromedp/chromedp v0.11.2/go.mod h1:lr8dFRLKsdTTWb75C/Ttol2vnBKOSnt0BW8R9Xaupi8= +github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= +github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= @@ -52,6 +58,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= @@ -61,6 +73,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= @@ -76,8 +90,12 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= @@ -89,6 +107,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= diff --git a/handlers/review.go b/handlers/review.go index 42b8052..8dc671a 100644 --- a/handlers/review.go +++ b/handlers/review.go @@ -7,6 +7,7 @@ import ( "net/http" "strconv" + "github.com/HoHD/PR-Helper/models" "github.com/HoHD/PR-Helper/services" "github.com/gin-gonic/gin" ) @@ -96,21 +97,143 @@ func (h *ReviewHandler) Review(c *gin.Context) { "top_n": topN, } resultJSON, _ := json.Marshal(resultData) - h.db.Exec(`INSERT INTO analyses (repo_id, type, base_ref, head_ref, result) VALUES (?, 'code_review', ?, ?, ?)`, + result, 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() + sendEvent("analysis_saved", map[string]interface{}{ + "analysis_id": analysisID, + }) + } } -// SaveNotes handles POST /api/repos/:id/review/notes — stub for Phase 5 +// SaveNotes handles POST /api/repos/:id/review/notes — upsert a review note. func (h *ReviewHandler) SaveNotes(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "notes not implemented yet — coming in Phase 5"}) + 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 + } + + 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) } -// GetNotes handles GET /api/repos/:id/review/notes — stub for Phase 5 +// GetNotes handles GET /api/repos/:id/review/notes — list review notes for an analysis. func (h *ReviewHandler) GetNotes(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "notes not implemented yet — coming in Phase 5"}) + 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 + } + + 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) } -// GeneratePDF handles POST /api/repos/:id/review/pdf — stub for Phase 5 +// GeneratePDF handles POST /api/repos/:id/review/pdf — generate and download a PDF report. func (h *ReviewHandler) GeneratePDF(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "pdf not implemented yet — coming in Phase 5"}) + id := c.Param("id") + + // Get repo info + var repoURL string + err := h.db.QueryRow(`SELECT url FROM repositories WHERE id = ?`, id).Scan(&repoURL) + 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 { + AnalysisID int64 `json:"analysis_id" binding:"required"` + Base string `json:"base"` + Head string `json:"head"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "analysis_id is required"}) + return + } + + // Load the analysis result + var analysisResult, baseRef, headRef string + var createdAt string + err = h.db.QueryRow(`SELECT result, base_ref, head_ref, created_at FROM analyses WHERE id = ?`, req.AnalysisID).Scan(&analysisResult, &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 + } + + // Load all notes for this analysis + notes, err := services.GetNotes(h.db, req.AnalysisID, "") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Build report data + reportData := services.ReportData{ + RepoURL: repoURL, + BaseRef: baseRef, + HeadRef: headRef, + ReviewedAt: createdAt, + AnalysisID: req.AnalysisID, + Result: analysisResult, + Notes: notes, + } + + // Generate PDF + pdfBytes, err := services.GeneratePDFReport(reportData) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "PDF generation failed: " + err.Error()}) + return + } + + // Return PDF as download + filename := fmt.Sprintf("pr-helper-review-%s.pdf", createdAt[:10]) + c.Header("Content-Type", "application/pdf") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) + c.Data(http.StatusOK, "application/pdf", pdfBytes) } diff --git a/services/notes.go b/services/notes.go new file mode 100644 index 0000000..d2892c4 --- /dev/null +++ b/services/notes.go @@ -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 +} diff --git a/services/pdf.go b/services/pdf.go new file mode 100644 index 0000000..c5149c7 --- /dev/null +++ b/services/pdf.go @@ -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 +} diff --git a/static/js/note-editor.js b/static/js/note-editor.js new file mode 100644 index 0000000..7b5bbd4 --- /dev/null +++ b/static/js/note-editor.js @@ -0,0 +1,296 @@ +// Note Editor: manages review notes with auto-save and three-level scoping +// Scopes: overall (page-level), file (per-file), suggestion (per-suggestion) +const NoteEditor = { + repoId: null, + analysisId: null, + _saveTimers: {}, // debounce timers by key + _notes: {}, // loaded notes keyed by "scope:scopeKey" + + /** + * Initialize the note editor for a review page. + * @param {number} repoId - Repository ID + * @param {number} analysisId - Analysis ID (from the most recent review) + */ + init(repoId, analysisId) { + this.repoId = repoId; + this.analysisId = analysisId; + this._notes = {}; + this._saveTimers = {}; + + // Bind existing note editor placeholders (from diff-viewer suggestion cards) + this._bindPlaceholders(); + + // Watch for new placeholders added dynamically (from SSE streaming) + this._observeNewPlaceholders(); + }, + + /** + * Load all notes for the current analysis and populate editors. + */ + async loadNotes() { + if (!this.analysisId) return; + + try { + const resp = await fetch(`/api/repos/${this.repoId}/review/notes?analysis_id=${this.analysisId}`); + if (!resp.ok) return; + const notes = await resp.json(); + + // Index notes + notes.forEach(n => { + const key = n.scope + ':' + (n.scope_key || ''); + this._notes[key] = n; + }); + + // Populate all existing editors + this._populateAllEditors(); + } catch (err) { + console.error('Failed to load notes:', err); + } + }, + + /** + * Save a note (upsert). Debounced to avoid rapid-fire requests. + */ + saveNote(scope, scopeKey, content) { + const key = scope + ':' + (scopeKey || ''); + + // Clear existing timer for this key + if (this._saveTimers[key]) { + clearTimeout(this._saveTimers[key]); + } + + // Debounce: wait 800ms after last keystroke + this._saveTimers[key] = setTimeout(async () => { + delete this._saveTimers[key]; + + try { + const resp = await fetch(`/api/repos/${this.repoId}/review/notes`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + analysis_id: this.analysisId, + scope: scope, + scope_key: scopeKey || '', + content: content, + }), + }); + + if (resp.ok) { + const note = await resp.json(); + this._notes[key] = note; + + // Update save indicator + const indicator = document.querySelector(`[data-note-key="${key}"] .note-save-indicator`); + if (indicator) { + indicator.textContent = '已保存'; + indicator.classList.remove('text-gray-400'); + indicator.classList.add('text-green-500'); + setTimeout(() => { + indicator.textContent = ''; + indicator.classList.remove('text-green-500'); + }, 2000); + } + } + } catch (err) { + console.error('Failed to save note:', err); + } + }, 800); + }, + + /** + * Create a note editor HTML for a given scope. + * @param {string} scope - 'overall', 'file', or 'suggestion' + * @param {string} scopeKey - empty for overall, filename for file, suggestionId for suggestion + * @returns {string} HTML string + */ + createEditorHTML(scope, scopeKey) { + const key = scope + ':' + (scopeKey || ''); + const existing = this._notes[key]; + const content = existing ? existing.content : ''; + const placeholder = this._getPlaceholder(scope); + + return ` +
总体评价: {{.Overall}}
+ {{end}} + {{if .Findings}} +主要发现:
+{{.Findings}}
+ {{end}} + {{if .Recommendations}} +改进建议:
+{{.Recommendations}}
+ {{end}} + {{if .OverallNotes}} +{{.}}
+ {{end}} +建议: {{.Suggestion}}
{{end}} + {{if .CodeExample}}{{.CodeExample}}{{end}}
+ {{.}}
+{{.Result}}
+