Compare commits
3
Commits
292588ef23
...
00c1839635
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00c1839635 | ||
|
|
6c60b767df | ||
|
|
37aa9c0a33 |
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()})
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 ──────────────────────────────────────────
|
||||
|
||||
@@ -427,6 +427,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Utilities ────────────────────────────────────────────────
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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
@@ -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}}
|
||||
🌿 分支: <span class="print-base-ref"></span> → <span class="print-head-ref"></span>
|
||||
📅 <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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user