Compare commits

...
3 Commits
Author SHA1 Message Date
wonder 00c1839635 feat: migrate database from SQLite to MySQL with .env configuration
- Replace go-sqlite3 with go-sql-driver/mysql (pure Go, no CGO needed)
- Add joho/godotenv for .env file loading
- Rewrite database/db.go with MySQL-compatible DDL (BIGINT AUTO_INCREMENT, InnoDB, utf8mb4)
- Convert all SQLite-specific SQL: INSERT OR IGNORE → INSERT IGNORE,
  INSERT OR REPLACE → INSERT ... ON DUPLICATE KEY UPDATE,
  datetime('now') → NOW(), date arithmetic → DATE_SUB()
- Add MySQL config fields to config.go (host/port/user/password/database)
- Add .env.example with connection parameter template
- Update Dockerfile: remove CGO/gcc/musl dependency, smaller build
- Update docker-compose.yml: add MySQL service container with healthcheck
- Update CLAUDE.md documentation
2026-06-20 22:40:46 +08:00
wonder 6c60b767df refactor: replace chromedp with browser native print for PDF export
Remove chromedp dependency, use window.print() instead. Docker image no longer needs Chromium (~200MB smaller).

- Delete services/pdf.go and templates/reports/review.html

- Remove PDF API route POST /api/repos/:id/review/pdf

- Add @media print CSS to review page

- Remove chromium from Dockerfile
2026-06-20 22:26:46 +08:00
wonder 37aa9c0a33 fix: 结构化渲染审查总结,替换原始JSON显示
- 后端发送结构化数据(score/overall/findings/recommendations)而非格式化文本
- 增加灵活JSON解析,兼容LLM返回recommendations为数组的情况
- 前端渲染带样式的审查总结:评分徽章(颜色分级)、分区展示
- 两个页面统一处理:repo页面的内联审查和review页面的完整审查
2026-06-20 22:15:00 +08:00
20 changed files with 329 additions and 631 deletions
+14
View File
@@ -0,0 +1,14 @@
# Server
PORT=8080
GIN_MODE=debug
SESSION_SECRET=
# Data directory (cloned repos, etc.)
DATA_DIR=data
# MySQL
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=root
MYSQL_PASSWORD=
MYSQL_DATABASE=pr_helper
+14 -11
View File
@@ -8,22 +8,25 @@ PR-Helper is a self-hosted web service that auto-generates PR descriptions from
## Tech Stack
- **Backend**: Go + Gin + SQLite (go-sqlite3) + go-git + chromedp (PDF generation)
- **Backend**: Go + Gin + MySQL + go-git
- **Frontend**: Go html/template + HTMX + D3.js + diff2html + Tailwind CSS
- **LLM**: OpenAI-compatible API (SSE streaming)
- **Deploy**: Docker (includes Chromium for PDF)
- **Deploy**: Docker
## Build & Run
```bash
# Build (CGO required for SQLite)
CGO_ENABLED=1 go build -o pr-helper .
# Build
go build -o pr-helper .
# Run
# Configure — copy .env.example to .env and set MySQL credentials
cp .env.example .env
# Run (requires MySQL server)
./pr-helper
# Server listens on :8080
# Docker
# Docker (includes MySQL container)
docker compose up --build
```
@@ -33,20 +36,19 @@ docker compose up --build
handlers/ → HTTP handlers (pages + JSON API + SSE endpoints)
services/ → Business logic (git ops, LLM calls, PDF gen, cache management)
models/ → Data models (repository, settings, analysis)
database/ → SQLite init and migrations
database/ → MySQL init and migrations
config/ → Configuration loading
templates/ → Go HTML templates (layouts/, pages/, partials/, reports/)
templates/ → Go HTML templates (layouts/, pages/, partials/)
static/ → CSS (Tailwind output), JS (graph, diff-viewer, sse), vendor libs
```
**Data flow**: Browser ↔ Gin handlers → services (git/llm/pdf) → SQLite + filesystem (`data/`)
**Data flow**: Browser ↔ Gin handlers → services (git/llm) → MySQL + filesystem (`data/`)
**Key service layer responsibilities**:
- `services/git.go` — clone, diff, graph data extraction via go-git
- `services/llm.go` — OpenAI-compatible API calls with SSE streaming
- `services/generate.go` — PR description generation (commits + diff → LLM → structured output)
- `services/review.go` — AI code review (per-file analysis + summary, Top-N strategy for large diffs)
- `services/pdf.go` — chromedp HTML→PDF conversion
- `services/cache.go` — repository cache lifecycle (clone, expiry cleanup)
## Key Patterns
@@ -64,8 +66,9 @@ static/ → CSS (Tailwind output), JS (graph, diff-viewer, sse), vendor libs
## Data Storage
- SQLite DB at `data/pr-helper.db` — settings (KV), repositories, analyses, review_notes
- MySQL database `pr_helper` — settings (KV), repositories, analyses, review_notes
- Cloned repos cached at `data/repos/` with configurable expiry (default 7 days)
- Database connection configured via `.env` file (see `.env.example`)
## Development Notes
+2 -4
View File
@@ -1,16 +1,14 @@
# Build stage
FROM golang:1.24-alpine AS builder
RUN apk add --no-cache gcc musl-dev
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=1 go build -o pr-helper .
RUN CGO_ENABLED=0 go build -o pr-helper .
# Runtime stage
FROM alpine:3.20
RUN apk add --no-cache ca-certificates chromium
ENV CHROME_BIN=/usr/bin/chromium-browser
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /app/pr-helper .
COPY --from=builder /app/templates ./templates
+20 -2
View File
@@ -3,8 +3,11 @@ package config
import (
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"github.com/joho/godotenv"
)
type Config struct {
@@ -12,14 +15,27 @@ type Config struct {
GinMode string
DataDir string
SessionSecret string
MySQLHost string
MySQLPort string
MySQLUser string
MySQLPassword string
MySQLDatabase string
}
func Load() *Config {
// Load .env file if present (ignore error if missing)
godotenv.Load()
cfg := &Config{
Port: getEnv("PORT", "8080"),
GinMode: getEnv("GIN_MODE", "debug"),
DataDir: getEnv("DATA_DIR", "data"),
SessionSecret: getEnv("SESSION_SECRET", ""),
MySQLHost: getEnv("MYSQL_HOST", "127.0.0.1"),
MySQLPort: getEnv("MYSQL_PORT", "3306"),
MySQLUser: getEnv("MYSQL_USER", "root"),
MySQLPassword: getEnv("MYSQL_PASSWORD", ""),
MySQLDatabase: getEnv("MYSQL_DATABASE", "pr_helper"),
}
// Generate random session secret if not provided
if cfg.SessionSecret == "" {
@@ -33,8 +49,10 @@ func Load() *Config {
return cfg
}
func (c *Config) DBPath() string {
return filepath.Join(c.DataDir, "pr-helper.db")
// MySQLDSN returns the MySQL Data Source Name for go-sql-driver/mysql.
func (c *Config) MySQLDSN() string {
return fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
c.MySQLUser, c.MySQLPassword, c.MySQLHost, c.MySQLPort, c.MySQLDatabase)
}
func (c *Config) ReposDir() string {
+40 -78
View File
@@ -4,7 +4,7 @@ import (
"database/sql"
"log"
_ "github.com/mattn/go-sqlite3"
_ "github.com/go-sql-driver/mysql"
"github.com/HoHD/PR-Helper/models"
)
@@ -13,11 +13,14 @@ type DB struct {
conn *sql.DB
}
func New(dbPath string) (*DB, error) {
conn, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
func New(dsn string) (*DB, error) {
conn, err := sql.Open("mysql", dsn)
if err != nil {
return nil, err
}
if err := conn.Ping(); err != nil {
return nil, err
}
db := &DB{conn: conn}
if err := db.migrate(); err != nil {
return nil, err
@@ -39,111 +42,70 @@ func (db *DB) Conn() *sql.DB {
func (db *DB) migrate() error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
key VARCHAR(255) PRIMARY KEY,
value TEXT NOT NULL
)`,
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
id BIGINT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS user_settings (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key TEXT NOT NULL,
user_id BIGINT NOT NULL,
key VARCHAR(255) NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (user_id, key)
)`,
PRIMARY KEY (user_id, key),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS repositories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT,
url TEXT NOT NULL,
local_path TEXT NOT NULL,
size_bytes INTEGER DEFAULT 0,
size_bytes BIGINT DEFAULT 0,
cloned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
last_used DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS analyses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
repo_id INTEGER REFERENCES repositories(id),
type TEXT NOT NULL,
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT,
repo_id BIGINT,
type VARCHAR(50) NOT NULL,
base_ref TEXT NOT NULL,
head_ref TEXT NOT NULL,
result TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
result LONGTEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (repo_id) REFERENCES repositories(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS review_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
analysis_id INTEGER REFERENCES analyses(id),
scope TEXT NOT NULL,
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT,
analysis_id BIGINT,
scope VARCHAR(50) NOT NULL,
scope_key TEXT NOT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (analysis_id) REFERENCES analyses(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
}
for _, s := range stmts {
if _, err := db.conn.Exec(s); err != nil {
return err
}
}
// Run incremental migrations for existing databases
if err := db.migrateV2(); err != nil {
return err
}
return nil
}
// migrateV2 adds user_id columns to existing tables and removes the old UNIQUE constraint on url.
func (db *DB) migrateV2() error {
migrations := []string{
`ALTER TABLE repositories ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE`,
`ALTER TABLE analyses ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE`,
`ALTER TABLE review_notes ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE`,
}
for _, m := range migrations {
// ALTER TABLE ADD COLUMN will fail if column already exists; ignore the error
db.conn.Exec(m)
}
// Recreate repositories table without the UNIQUE constraint on url (same url can be cloned by different users)
// SQLite doesn't support DROP CONSTRAINT, so we use a workaround
var hasOldUnique int
db.conn.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='repositories_old'`).Scan(&hasOldUnique)
if hasOldUnique == 0 {
// Check if the UNIQUE constraint exists by trying to detect the old schema
// We'll just do the migration idempotently
db.conn.Exec(`CREATE TABLE IF NOT EXISTS repositories_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
url TEXT NOT NULL,
local_path TEXT NOT NULL,
size_bytes INTEGER DEFAULT 0,
cloned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used DATETIME DEFAULT CURRENT_TIMESTAMP
)`)
db.conn.Exec(`INSERT OR IGNORE INTO repositories_new (id, user_id, url, local_path, size_bytes, cloned_at, last_used)
SELECT id, user_id, url, local_path, size_bytes, cloned_at, last_used FROM repositories`)
// Only swap if the new table has data or old table is empty
var newCount, oldCount int
db.conn.QueryRow(`SELECT COUNT(*) FROM repositories_new`).Scan(&newCount)
db.conn.QueryRow(`SELECT COUNT(*) FROM repositories`).Scan(&oldCount)
if newCount >= oldCount {
db.conn.Exec(`DROP TABLE repositories`)
db.conn.Exec(`ALTER TABLE repositories_new RENAME TO repositories`)
} else {
db.conn.Exec(`DROP TABLE repositories_new`)
}
}
return nil
}
func (db *DB) seedDefaults() error {
for key, val := range models.DefaultSettings {
_, err := db.conn.Exec(
`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`, key, val,
`INSERT IGNORE INTO settings (key, value) VALUES (?, ?)`, key, val,
)
if err != nil {
log.Printf("warning: failed to seed setting %s: %v", key, err)
+25
View File
@@ -1,4 +1,20 @@
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_PASSWORD:-pr_helper_pass}
MYSQL_DATABASE: ${MYSQL_DATABASE:-pr_helper}
volumes:
- mysql-data:/var/lib/mysql
ports:
- "3306:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
pr-helper:
build: .
ports:
@@ -7,7 +23,16 @@ services:
- pr-helper-data:/app/data
environment:
- GIN_MODE=release
- MYSQL_HOST=mysql
- MYSQL_PORT=3306
- MYSQL_USER=root
- MYSQL_PASSWORD=${MYSQL_PASSWORD:-pr_helper_pass}
- MYSQL_DATABASE=${MYSQL_DATABASE:-pr_helper}
depends_on:
mysql:
condition: service_healthy
restart: unless-stopped
volumes:
mysql-data:
pr-helper-data:
+3 -9
View File
@@ -3,24 +3,23 @@ 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-contrib/sessions v1.1.0
github.com/gin-gonic/gin v1.12.0
github.com/go-git/go-git/v5 v5.19.1
github.com/mattn/go-sqlite3 v1.14.24
github.com/go-sql-driver/mysql v1.10.0
github.com/joho/godotenv v1.5.1
github.com/sashabaranov/go-openai v1.41.2
golang.org/x/crypto v0.50.0
)
require (
dario.cat/mergo v1.0.0 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
@@ -32,9 +31,6 @@ 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.30.1 // 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.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
@@ -42,12 +38,10 @@ require (
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0 // 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
+6 -22
View File
@@ -1,5 +1,7 @@
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
@@ -15,12 +17,6 @@ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uS
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
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.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
@@ -60,12 +56,8 @@ 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.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
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/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
@@ -85,8 +77,8 @@ github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzq
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
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=
@@ -100,16 +92,10 @@ 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=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -117,8 +103,6 @@ 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.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
+1 -1
View File
@@ -126,7 +126,7 @@ func (h *AuthHandler) HandleRegister(c *gin.Context) {
// Seed default settings for the user
for key, val := range defaultUserSettings {
h.db.Exec(`INSERT OR IGNORE INTO user_settings (user_id, key, value) VALUES (?, ?, ?)`, userID, key, val)
h.db.Exec(`INSERT IGNORE INTO user_settings (user_id, key, value) VALUES (?, ?, ?)`, userID, key, val)
}
// Auto-login: set session
+1 -1
View File
@@ -73,7 +73,7 @@ func (h *GenerateHandler) Generate(c *gin.Context) {
}
// Update last_used
h.db.Exec(`UPDATE repositories SET last_used = datetime('now') WHERE id = ?`, id)
h.db.Exec(`UPDATE repositories SET last_used = NOW() WHERE id = ?`, id)
// Generate PR description (pass user ID for per-user LLM config)
pr, err := services.GeneratePR(h.db, localPath, req.Base, req.Head, user.ID, sendEvent)
+3 -3
View File
@@ -104,7 +104,7 @@ func (h *ReposHandler) CleanupRepos(c *gin.Context) {
if maxAgeDays == "" {
maxAgeDays = "7"
}
rows, err := h.db.Query(`SELECT id, local_path FROM repositories WHERE user_id = ? AND last_used < datetime('now', '-' || ? || ' days')`, user.ID, maxAgeDays)
rows, err := h.db.Query(`SELECT id, local_path FROM repositories WHERE user_id = ? AND last_used < DATE_SUB(NOW(), INTERVAL ? DAY)`, user.ID, maxAgeDays)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -244,7 +244,7 @@ func (h *ReposHandler) GetGraph(c *gin.Context) {
}
// Update last_used
h.db.Exec(`UPDATE repositories SET last_used = datetime('now') WHERE id = ?`, id)
h.db.Exec(`UPDATE repositories SET last_used = NOW() WHERE id = ?`, id)
repo, err := services.OpenRepo(localPath)
if err != nil {
@@ -297,7 +297,7 @@ func (h *ReposHandler) GetDiff(c *gin.Context) {
}
// Update last_used
h.db.Exec(`UPDATE repositories SET last_used = datetime('now') WHERE id = ?`, id)
h.db.Exec(`UPDATE repositories SET last_used = NOW() WHERE id = ?`, id)
repo, err := services.OpenRepo(localPath)
if err != nil {
+1 -83
View File
@@ -105,7 +105,7 @@ func (h *ReviewHandler) Review(c *gin.Context) {
}
// Update last_used
h.db.Exec(`UPDATE repositories SET last_used = datetime('now') WHERE id = ?`, id)
h.db.Exec(`UPDATE repositories SET last_used = NOW() WHERE id = ?`, id)
// Run AI review (pass user ID for per-user LLM config)
reviewResult, err := services.GenerateReview(h.db, localPath, req.Base, req.Head, topN, concurrency, user.ID, sendEvent)
@@ -309,85 +309,3 @@ func (h *ReviewHandler) GetReview(c *gin.Context) {
"result": reviewResult,
})
}
// GeneratePDF handles POST /api/repos/:id/review/pdf — generate and download a PDF report.
func (h *ReviewHandler) GeneratePDF(c *gin.Context) {
user := GetCurrentUser(c)
if user == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthenticated"})
return
}
id := c.Param("id")
// Get repo info (scoped to user)
var repoURL string
err := h.db.QueryRow(`SELECT url FROM repositories WHERE id = ? AND user_id = ?`, id, user.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
}
// Verify analysis belongs to user
var analysisResult, baseRef, headRef string
var createdAt string
err = h.db.QueryRow(`SELECT result, base_ref, head_ref, created_at FROM analyses WHERE id = ? AND user_id = ?`, req.AnalysisID, user.ID).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
dateStr := createdAt
if len(dateStr) > 10 {
dateStr = dateStr[:10]
}
filename := fmt.Sprintf("pr-helper-review-%s.pdf", dateStr)
c.Header("Content-Type", "application/pdf")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
c.Data(http.StatusOK, "application/pdf", pdfBytes)
}
+1 -1
View File
@@ -56,7 +56,7 @@ func (h *SettingsHandler) UpdateSettings(c *gin.Context) {
return
}
for key, val := range body {
_, err := tx.Exec(`INSERT OR REPLACE INTO user_settings (user_id, key, value) VALUES (?, ?, ?)`, user.ID, key, val)
_, err := tx.Exec(`INSERT INTO user_settings (user_id, key, value) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE value = VALUES(value)`, user.ID, key, val)
if err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+2 -2
View File
@@ -25,7 +25,7 @@ import (
func main() {
cfg := config.Load()
db, err := database.New(cfg.DBPath())
db, err := database.New(cfg.MySQLDSN())
if err != nil {
log.Fatalf("failed to initialize database: %v", err)
}
@@ -124,7 +124,7 @@ func main() {
r.GET("/api/repos/:id/review/analyses/:aid", authMw, review.GetReview)
r.POST("/api/repos/:id/review/notes", authMw, review.SaveNotes)
r.GET("/api/repos/:id/review/notes", authMw, review.GetNotes)
r.POST("/api/repos/:id/review/pdf", authMw, review.GeneratePDF)
// Graceful shutdown with signal handling
srv := &http.Server{
-222
View File
@@ -1,222 +0,0 @@
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 (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 {
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
}
+45 -13
View File
@@ -287,9 +287,9 @@ func GenerateReview(db *sql.DB, repoPath, base, head string, topN, concurrency i
"score": 7,
"overall": "总体评价(2-3 句话)",
"findings": "按严重程度排序的主要发现汇总",
"recommendations": "改进建议优先级列表"
"recommendations": "改进建议,用换行分隔多条建议"
}
请用中文回复。`, strings.Join(reviewParts, "\n\n"))
注意:所有字段必须是字符串类型,不要使用数组。请用中文回复。`, strings.Join(reviewParts, "\n\n"))
messages := []goopenai.ChatCompletionMessage{
{Role: goopenai.ChatMessageRoleUser, Content: summaryPrompt},
@@ -299,20 +299,52 @@ func GenerateReview(db *sql.DB, repoPath, base, head string, topN, concurrency i
if err == nil {
jsonStr := extractJSON(summaryResponse)
if json.Unmarshal([]byte(jsonStr), &summary) == nil {
summaryText := fmt.Sprintf("整体评分: %d/10\n\n%s", summary.Score, summary.Overall)
if summary.Findings != "" {
summaryText += "\n\n**主要发现:**\n" + summary.Findings
}
if summary.Recommendations != "" {
summaryText += "\n\n**改进建议:**\n" + summary.Recommendations
}
if callback != nil {
callback("summary", map[string]interface{}{"content": summaryText})
callback("summary", map[string]interface{}{
"score": summary.Score,
"overall": summary.Overall,
"findings": summary.Findings,
"recommendations": summary.Recommendations,
})
}
} else {
// If parsing fails, send raw response as summary
if callback != nil {
callback("summary", map[string]interface{}{"content": summaryResponse})
// Try flexible parsing (handle recommendations as array)
var raw map[string]json.RawMessage
if json.Unmarshal([]byte(jsonStr), &raw) == nil {
var flexible struct {
Score int `json:"score"`
Overall string `json:"overall"`
Findings string `json:"findings"`
}
json.Unmarshal(raw["score"], &flexible.Score)
json.Unmarshal(raw["overall"], &flexible.Overall)
json.Unmarshal(raw["findings"], &flexible.Findings)
recommendations := ""
if rec, ok := raw["recommendations"]; ok {
var arr []string
if json.Unmarshal(rec, &arr) == nil {
recommendations = strings.Join(arr, "\n")
} else {
var s string
json.Unmarshal(rec, &s)
recommendations = s
}
}
if callback != nil {
callback("summary", map[string]interface{}{
"score": flexible.Score,
"overall": flexible.Overall,
"findings": flexible.Findings,
"recommendations": recommendations,
})
}
} else {
// If all parsing fails, send raw response as summary
if callback != nil {
callback("summary", map[string]interface{}{"content": summaryResponse})
}
}
}
} else {
+4 -44
View File
@@ -149,55 +149,15 @@ const NoteEditor = {
},
/**
* Trigger PDF export and download.
* Trigger PDF export via browser's native print dialog.
* Users can select "Save as PDF" in the print dialog.
*/
async exportPDF() {
exportPDF() {
if (!this.analysisId) {
showToast('请先完成审查再导出 PDF', 'warning');
return;
}
const btn = document.getElementById('btn-export-pdf');
if (btn) {
btn.disabled = true;
btn.textContent = '⏳ 生成中...';
}
try {
const resp = await fetch(`/api/repos/${this.repoId}/review/pdf`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
analysis_id: this.analysisId,
base: document.getElementById('base-ref')?.value || '',
head: document.getElementById('head-ref')?.value || '',
}),
});
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.error || 'PDF generation failed');
}
// Download the PDF
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `pr-helper-review-${new Date().toISOString().slice(0, 10)}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
showToast('PDF 导出失败: ' + err.message, 'error');
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = '📄 导出 PDF 报告';
}
}
window.print();
},
// ── Internal helpers ──────────────────────────────────────────
+34 -1
View File
@@ -427,6 +427,34 @@
}
}
// ── Utilities ────────────────────────────────────────────────
function escapeHtml(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function renderSummaryHtml(data) {
let html = '<div class="space-y-3">';
const score = data.score || 0;
const scoreColor = score >= 7 ? 'bg-green-100 text-green-800' : score >= 4 ? 'bg-yellow-100 text-yellow-800' : 'bg-red-100 text-red-800';
html += `<div class="flex items-center gap-2"><span class="text-sm font-medium text-gray-600">整体评分</span><span class="px-2 py-0.5 rounded-full text-xs font-semibold ${scoreColor}">${score}/10</span></div>`;
if (data.overall) {
html += `<div><span class="text-sm font-medium text-gray-600">总体评价</span><p class="mt-1 text-sm text-gray-800">${escapeHtml(data.overall)}</p></div>`;
}
if (data.findings) {
html += `<div><span class="text-sm font-medium text-gray-600">主要发现</span><p class="mt-1 text-sm text-gray-800">${escapeHtml(data.findings)}</p></div>`;
}
if (data.recommendations) {
html += `<div><span class="text-sm font-medium text-gray-600">改进建议</span><p class="mt-1 text-sm text-gray-800">${escapeHtml(data.recommendations)}</p></div>`;
}
html += '</div>';
return html;
}
// ── Inline AI Review ────────────────────────────────────────
let inlineReviewSSE = null;
@@ -476,7 +504,12 @@
inlineSuggestions.push(data);
},
summary(data) {
document.getElementById('review-summary-content').innerHTML = renderInlineMarkdown(data.content || '');
const el = document.getElementById('review-summary-content');
if (data.score !== undefined) {
el.innerHTML = renderSummaryHtml(data);
} else {
el.innerHTML = renderInlineMarkdown(data.content || '');
}
document.getElementById('review-progress-bar').style.width = '90%';
},
done() {
+113 -8
View File
@@ -10,6 +10,61 @@
<script src="/static/js/diff-viewer.js"></script>
<script src="/static/js/review-inline.js"></script>
<script src="/static/js/note-editor.js"></script>
<style>
@media print {
/* Hide non-content elements */
nav, footer, #review-actions, #btn-review, #btn-toggle-diff,
.note-save-indicator, select, input, #progress,
#diff-review-container, #history-select,
label[for="base-ref"], label[for="head-ref"],
label[for="top-n"], label[for="concurrency"] { display: none !important; }
/* Show results and print header even if hidden */
#results { display: block !important; }
.print-header { display: block !important; }
/* Page setup */
body { background: white !important; padding: 0 !important; font-size: 12px; }
main { max-width: 100% !important; padding: 16px !important; }
/* Cards without shadow */
.bg-white { box-shadow: none !important; border: 1px solid #e5e7eb; }
/* Avoid page breaks inside cards */
.bg-white.rounded-lg { break-inside: avoid; }
/* File review cards */
#file-reviews > div { break-inside: avoid; margin-bottom: 12px; }
/* Note editors: show content as plain text */
.note-textarea {
border: none !important;
padding: 0 !important;
resize: none !important;
height: auto !important;
overflow: visible !important;
white-space: pre-wrap;
background: #f0f9ff !important;
padding: 8px !important;
border-left: 3px solid #3b82f6 !important;
}
/* Suggestion cards: ensure colors print */
.suggestion-card { break-inside: avoid; }
* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
/* Score badge */
.rounded-full { border: 1px solid currentColor; }
/* Branch info as text */
#base-ref, #head-ref, #top-n, #concurrency {
display: inline !important;
border: none !important;
padding: 0 !important;
font-weight: 600;
}
}
</style>
</head>
<body class="bg-gray-50 min-h-screen">
{{template "nav" .}}
@@ -71,6 +126,16 @@
<!-- Results -->
<div id="results" class="hidden">
<!-- Print-only header -->
<div class="print-header hidden mb-6">
<h1 style="font-size:20px;font-weight:700;color:#111827;margin-bottom:8px;">PR-Helper 审查报告</h1>
<p style="color:#6b7280;font-size:12px;">
📦 仓库: {{.RepoURL}}
&nbsp;&nbsp;🌿 分支: <span class="print-base-ref"></span> → <span class="print-head-ref"></span>
&nbsp;&nbsp;📅 <span class="print-date"></span>
</p>
<hr style="margin-top:12px;border:none;border-top:1px solid #e5e7eb;">
</div>
<!-- Summary -->
<div id="summary" class="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">审查总结</h2>
@@ -126,6 +191,34 @@
let fileReviews = {};
let currentAnalysisId = null;
// ── Utilities ────────────────────────────────────────────────
function escapeHtml(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function renderSummaryHtml(data) {
let html = '<div class="space-y-3">';
const score = data.score || 0;
const scoreColor = score >= 7 ? 'bg-green-100 text-green-800' : score >= 4 ? 'bg-yellow-100 text-yellow-800' : 'bg-red-100 text-red-800';
html += `<div class="flex items-center gap-2"><span class="text-sm font-medium text-gray-600">整体评分</span><span class="px-2 py-0.5 rounded-full text-xs font-semibold ${scoreColor}">${score}/10</span></div>`;
if (data.overall) {
html += `<div><span class="text-sm font-medium text-gray-600">总体评价</span><p class="mt-1 text-sm text-gray-800">${escapeHtml(data.overall)}</p></div>`;
}
if (data.findings) {
html += `<div><span class="text-sm font-medium text-gray-600">主要发现</span><p class="mt-1 text-sm text-gray-800">${escapeHtml(data.findings)}</p></div>`;
}
if (data.recommendations) {
html += `<div><span class="text-sm font-medium text-gray-600">改进建议</span><p class="mt-1 text-sm text-gray-800">${escapeHtml(data.recommendations)}</p></div>`;
}
html += '</div>';
return html;
}
// Load refs on page load
async function loadRefs() {
try {
@@ -310,7 +403,12 @@
}
},
summary(data) {
document.getElementById('summary-content').innerHTML = renderMarkdown(data.content || '');
const el = document.getElementById('summary-content');
if (data.score !== undefined) {
el.innerHTML = renderSummaryHtml(data);
} else {
el.innerHTML = renderMarkdown(data.content || '');
}
document.getElementById('progress-bar').style.width = '90%';
document.getElementById('progress-text').textContent = '生成总结...';
},
@@ -324,6 +422,7 @@
document.getElementById('review-actions').classList.remove('hidden');
btn.disabled = false;
btn.textContent = '开始审查';
updatePrintHeader();
}, 500);
// Render inline suggestions if diff is available
@@ -433,6 +532,17 @@
return Markdown.render(text);
}
// Update print-only header with current branch info
function updatePrintHeader() {
const baseEl = document.getElementById('base-ref');
const headEl = document.getElementById('head-ref');
const baseText = baseEl?.options[baseEl.selectedIndex]?.text || baseEl?.value || '';
const headText = headEl?.options[headEl.selectedIndex]?.text || headEl?.value || '';
document.querySelectorAll('.print-base-ref').forEach(el => el.textContent = baseText);
document.querySelectorAll('.print-head-ref').forEach(el => el.textContent = headText);
document.querySelectorAll('.print-date').forEach(el => el.textContent = new Date().toLocaleString('zh-CN'));
}
// ── History loading ────────────────────────────────────────
async function loadHistoryList() {
@@ -497,13 +607,7 @@
// Render summary
const summary = result.summary || {};
let summaryText = '';
if (summary.score) summaryText += `整体评分: ${summary.score}/10\n\n`;
if (summary.overall) summaryText += summary.overall;
if (summary.findings) summaryText += '\n\n**主要发现:**\n' + summary.findings;
if (summary.recommendations) summaryText += '\n\n**改进建议:**\n' + summary.recommendations;
document.getElementById('summary-content').innerHTML = renderMarkdown(summaryText);
document.getElementById('summary-content').innerHTML = renderSummaryHtml(summary);
// Clear and rebuild card view
document.getElementById('file-reviews').innerHTML = '';
@@ -512,6 +616,7 @@
// Show results
document.getElementById('results').classList.remove('hidden');
document.getElementById('review-actions').classList.remove('hidden');
updatePrintHeader();
// Load diff and render inline
const base = data.base_ref;
-126
View File
@@ -1,126 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>PR-Helper 审查报告</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans SC", sans-serif;
font-size: 13px;
line-height: 1.6;
color: #1f2937;
padding: 40px;
max-width: 900px;
margin: 0 auto;
}
h1 { font-size: 22px; margin-bottom: 8px; color: #111827; }
h2 { font-size: 16px; margin: 24px 0 12px; color: #111827; border-bottom: 2px solid #e5e7eb; padding-bottom: 6px; }
h3 { font-size: 14px; margin: 16px 0 8px; color: #374151; }
p { margin-bottom: 8px; }
.meta { color: #6b7280; font-size: 12px; margin-bottom: 20px; }
.meta span { display: inline-block; margin-right: 16px; }
.score { font-size: 28px; font-weight: 700; color: #2563eb; }
.score-label { font-size: 14px; color: #6b7280; margin-left: 4px; }
.section { margin-bottom: 24px; padding: 16px; border: 1px solid #e5e7eb; border-radius: 8px; }
.severity { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; margin-right: 6px; }
.severity-critical { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
.severity-warning { background: #fefce8; color: #854d0e; border: 1px solid #fef08a; }
.severity-info { background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0; }
.file-section { margin-bottom: 16px; padding: 12px; border: 1px solid #e5e7eb; border-radius: 6px; }
.file-header { font-weight: 600; font-family: monospace; font-size: 13px; margin-bottom: 8px; color: #111827; }
.suggestion { margin: 8px 0; padding: 8px 12px; border-radius: 6px; font-size: 12px; }
.suggestion-critical { border-left: 3px solid #ef4444; background: #fef2f2; }
.suggestion-warning { border-left: 3px solid #f59e0b; background: #fefce8; }
.suggestion-info { border-left: 3px solid #22c55e; background: #f0fdf4; }
.note { margin-top: 8px; padding: 8px 12px; background: #f0f9ff; border-left: 3px solid #3b82f6; font-size: 12px; border-radius: 4px; }
.note-label { font-size: 11px; color: #6b7280; margin-bottom: 4px; }
.overall-note { padding: 12px 16px; background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px; margin-top: 12px; }
pre { background: #f8fafc; border: 1px solid #e2e8f0; padding: 10px; border-radius: 6px; font-family: monospace; font-size: 12px; overflow-x: auto; margin: 8px 0; white-space: pre-wrap; word-wrap: break-word; }
code { font-family: monospace; background: #f1f5f9; padding: 1px 4px; border-radius: 3px; font-size: 12px; }
pre code { background: none; padding: 0; }
.footer { margin-top: 40px; padding-top: 16px; border-top: 1px solid #e5e7eb; text-align: center; color: #9ca3af; font-size: 11px; }
@media print {
body { padding: 20px; }
.section { break-inside: avoid; }
.file-section { break-inside: avoid; }
}
</style>
</head>
<body>
<h1>PR-Helper 审查报告</h1>
<div class="meta">
<span>📦 仓库: {{.RepoURL}}</span>
<span>🌿 分支: {{.BaseRef}} → {{.HeadRef}}</span>
<span>📅 审查时间: {{.ReviewedAt}}</span>
</div>
<!-- Overall Assessment -->
<div class="section">
<h2>整体评估</h2>
{{if .Score}}
<div style="margin-bottom: 12px;">
<span class="score">{{.Score}}</span><span class="score-label">/10</span>
</div>
{{end}}
{{if .Overall}}
<p><strong>总体评价:</strong> {{.Overall}}</p>
{{end}}
{{if .Findings}}
<p><strong>主要发现:</strong></p>
<p>{{.Findings}}</p>
{{end}}
{{if .Recommendations}}
<p><strong>改进建议:</strong></p>
<p>{{.Recommendations}}</p>
{{end}}
{{if .OverallNotes}}
<div class="overall-note">
<div class="note-label">💬 用户备注:</div>
{{range .OverallNotes}}
<p>{{.}}</p>
{{end}}
</div>
{{end}}
</div>
<!-- File Reviews -->
{{if .FileReviews}}
<h2>逐文件审查</h2>
{{range .FileReviews}}
<div class="file-section">
<div class="file-header">📄 {{.FileName}}
{{if .ChangeLines}}<span style="font-weight:normal;color:#6b7280;font-size:12px;">({{.ChangeLines}} 行变更)</span>{{end}}
</div>
{{range .Suggestions}}
<div class="suggestion suggestion-{{.Severity}}">
<span class="severity severity-{{.Severity}}">{{.SeverityCN}}</span>
<strong>{{.Description}}</strong>
{{if .Suggestion}}<p style="margin-top:4px;">建议: {{.Suggestion}}</p>{{end}}
{{if .CodeExample}}<pre><code>{{.CodeExample}}</code></pre>{{end}}
</div>
{{end}}
{{range .Notes}}
<div class="note">
<div class="note-label">💬 备注:</div>
<p>{{.}}</p>
</div>
{{end}}
</div>
{{end}}
{{end}}
<!-- Raw result fallback if no structured data -->
{{if and (not .FileReviews) .Result}}
<div class="section">
<h2>审查详情</h2>
<pre>{{.Result}}</pre>
</div>
{{end}}
<div class="footer">
PR-Helper — AI 代码审查报告 · {{.ReviewedAt}}
</div>
</body>
</html>