feat: Phase 1 — 项目骨架搭建,集成 Gin、SQLite 和设置页面

This commit is contained in:
2026-06-18 22:48:16 +08:00
parent 46f1bbf0f1
commit 1e9b1a2129
26 changed files with 2518 additions and 0 deletions
+5
View File
@@ -25,3 +25,8 @@ go.work.sum
# env file
.env
# Project specific
pr-helper
data/
*.db
+39
View File
@@ -0,0 +1,39 @@
package config
import (
"os"
"path/filepath"
)
type Config struct {
Port string
GinMode string
DataDir string
}
func Load() *Config {
cfg := &Config{
Port: getEnv("PORT", "8080"),
GinMode: getEnv("GIN_MODE", "debug"),
DataDir: getEnv("DATA_DIR", "data"),
}
// Ensure data directories exist
os.MkdirAll(cfg.DataDir, 0o755)
os.MkdirAll(filepath.Join(cfg.DataDir, "repos"), 0o755)
return cfg
}
func (c *Config) DBPath() string {
return filepath.Join(c.DataDir, "pr-helper.db")
}
func (c *Config) ReposDir() string {
return filepath.Join(c.DataDir, "repos")
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
+90
View File
@@ -0,0 +1,90 @@
package database
import (
"database/sql"
"log"
_ "github.com/mattn/go-sqlite3"
"github.com/HoHD/PR-Helper/models"
)
type DB struct {
conn *sql.DB
}
func New(dbPath string) (*DB, error) {
conn, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
if err != nil {
return nil, err
}
db := &DB{conn: conn}
if err := db.migrate(); err != nil {
return nil, err
}
if err := db.seedDefaults(); err != nil {
return nil, err
}
return db, nil
}
func (db *DB) Close() error {
return db.conn.Close()
}
func (db *DB) Conn() *sql.DB {
return db.conn
}
func (db *DB) migrate() error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS repositories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL UNIQUE,
local_path TEXT NOT NULL,
size_bytes INTEGER DEFAULT 0,
cloned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS analyses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER REFERENCES repositories(id),
type TEXT NOT NULL,
base_ref TEXT NOT NULL,
head_ref TEXT NOT NULL,
result TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS review_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
analysis_id INTEGER REFERENCES analyses(id),
scope TEXT NOT NULL,
scope_key TEXT NOT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
}
for _, s := range stmts {
if _, err := db.conn.Exec(s); err != nil {
return err
}
}
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,
)
if err != nil {
log.Printf("warning: failed to seed setting %s: %v", key, err)
}
}
return nil
}
+37
View File
@@ -0,0 +1,37 @@
module github.com/HoHD/PR-Helper
go 1.22.2
require (
github.com/gin-gonic/gin v1.10.0
github.com/mattn/go-sqlite3 v1.14.24
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // 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
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+91
View File
@@ -0,0 +1,91 @@
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
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/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
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/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=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+21
View File
@@ -0,0 +1,21 @@
package handlers
import (
"database/sql"
"net/http"
"github.com/gin-gonic/gin"
)
type GenerateHandler struct {
db *sql.DB
}
func NewGenerateHandler(db *sql.DB) *GenerateHandler {
return &GenerateHandler{db: db}
}
// Generate handles POST /api/repos/:id/generate — stub for Phase 4
func (h *GenerateHandler) Generate(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "generate not implemented yet — coming in Phase 4"})
}
+83
View File
@@ -0,0 +1,83 @@
package handlers
import (
"database/sql"
"net/http"
"github.com/gin-gonic/gin"
"github.com/HoHD/PR-Helper/models"
)
type PageHandler struct {
db *sql.DB
}
func NewPageHandler(db *sql.DB) *PageHandler {
return &PageHandler{db: db}
}
func (h *PageHandler) Index(c *gin.Context) {
repos, _ := h.listRepos()
c.HTML(http.StatusOK, "pages/index.html", gin.H{
"Repos": repos,
})
}
func (h *PageHandler) Repo(c *gin.Context) {
c.HTML(http.StatusOK, "pages/repo.html", gin.H{
"ID": c.Param("id"),
})
}
func (h *PageHandler) Generate(c *gin.Context) {
c.HTML(http.StatusOK, "pages/generate.html", gin.H{
"ID": c.Param("id"),
})
}
func (h *PageHandler) Review(c *gin.Context) {
topN := "20"
h.db.QueryRow(`SELECT value FROM settings WHERE key = 'review.top_n'`).Scan(&topN)
c.HTML(http.StatusOK, "pages/review.html", gin.H{
"ID": c.Param("id"),
"TopN": topN,
})
}
func (h *PageHandler) Settings(c *gin.Context) {
settings := make(map[string]string)
for key := range models.DefaultSettings {
settings[key] = ""
}
rows, err := h.db.Query(`SELECT key, value FROM settings`)
if err == nil {
defer rows.Close()
for rows.Next() {
var k, v string
if rows.Scan(&k, &v) == nil {
settings[k] = v
}
}
}
c.HTML(http.StatusOK, "pages/settings.html", gin.H{
"Settings": settings,
})
}
func (h *PageHandler) listRepos() ([]models.Repository, error) {
rows, err := h.db.Query(`SELECT id, url, local_path, size_bytes, cloned_at, last_used FROM repositories ORDER BY last_used DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var repos []models.Repository
for rows.Next() {
var r models.Repository
if err := rows.Scan(&r.ID, &r.URL, &r.LocalPath, &r.SizeBytes, &r.ClonedAt, &r.LastUsed); err != nil {
continue
}
repos = append(repos, r)
}
return repos, nil
}
+121
View File
@@ -0,0 +1,121 @@
package handlers
import (
"database/sql"
"net/http"
"os"
"path/filepath"
"github.com/gin-gonic/gin"
)
type ReposHandler struct {
db *sql.DB
reposDir string
}
func NewReposHandler(db *sql.DB, reposDir string) *ReposHandler {
return &ReposHandler{db: db, reposDir: reposDir}
}
func (h *ReposHandler) ListRepos(c *gin.Context) {
rows, err := h.db.Query(`SELECT id, url, local_path, size_bytes, cloned_at, last_used FROM repositories ORDER BY last_used DESC`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer rows.Close()
var repos []gin.H
for rows.Next() {
var id int64
var url, localPath string
var sizeBytes int64
var clonedAt, lastUsed string
if rows.Scan(&id, &url, &localPath, &sizeBytes, &clonedAt, &lastUsed) == nil {
repos = append(repos, gin.H{
"id": id, "url": url, "local_path": localPath,
"size_bytes": sizeBytes, "cloned_at": clonedAt, "last_used": lastUsed,
})
}
}
if repos == nil {
repos = []gin.H{}
}
c.JSON(http.StatusOK, repos)
}
func (h *ReposHandler) DeleteRepo(c *gin.Context) {
id := c.Param("id")
var localPath string
err := h.db.QueryRow(`SELECT local_path FROM repositories WHERE id = ?`, id).Scan(&localPath)
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
}
// Remove from filesystem
os.RemoveAll(localPath)
// Remove from database
h.db.Exec(`DELETE FROM analyses WHERE repo_id = ?`, id)
h.db.Exec(`DELETE FROM repositories WHERE id = ?`, id)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *ReposHandler) CleanupRepos(c *gin.Context) {
// Get max age from settings
var maxAgeDays string
h.db.QueryRow(`SELECT value FROM settings WHERE key = 'cache.max_age_days'`).Scan(&maxAgeDays)
if maxAgeDays == "" {
maxAgeDays = "7"
}
// Find expired repos
rows, err := h.db.Query(`SELECT id, local_path FROM repositories WHERE last_used < datetime('now', '-' || ? || ' days')`, maxAgeDays)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer rows.Close()
var cleaned []int64
for rows.Next() {
var id int64
var localPath string
if rows.Scan(&id, &localPath) == nil {
os.RemoveAll(localPath)
h.db.Exec(`DELETE FROM analyses WHERE repo_id = ?`, id)
h.db.Exec(`DELETE FROM repositories WHERE id = ?`, id)
cleaned = append(cleaned, id)
}
}
c.JSON(http.StatusOK, gin.H{"cleaned": cleaned})
}
// CloneRepo handles POST /api/repos — stub for Phase 2
func (h *ReposHandler) CloneRepo(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "clone not implemented yet — coming in Phase 2"})
}
// GetGraph handles GET /api/repos/:id/graph — stub for Phase 2
func (h *ReposHandler) GetGraph(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "graph not implemented yet — coming in Phase 2"})
}
// GetDiff handles GET /api/repos/:id/diff — stub for Phase 2
func (h *ReposHandler) GetDiff(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "diff not implemented yet — coming in Phase 2"})
}
// dirSize returns the total size of a directory in bytes
func dirSize(path string) int64 {
var size int64
filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
size += info.Size()
return nil
})
return size
}
+36
View File
@@ -0,0 +1,36 @@
package handlers
import (
"database/sql"
"net/http"
"github.com/gin-gonic/gin"
)
type ReviewHandler struct {
db *sql.DB
}
func NewReviewHandler(db *sql.DB) *ReviewHandler {
return &ReviewHandler{db: db}
}
// Review handles POST /api/repos/:id/review — stub for Phase 4
func (h *ReviewHandler) Review(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "review not implemented yet — coming in Phase 4"})
}
// SaveNotes handles POST /api/repos/:id/review/notes — stub for Phase 5
func (h *ReviewHandler) SaveNotes(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "notes not implemented yet — coming in Phase 5"})
}
// GetNotes handles GET /api/repos/:id/review/notes — stub for Phase 5
func (h *ReviewHandler) GetNotes(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "notes not implemented yet — coming in Phase 5"})
}
// GeneratePDF handles POST /api/repos/:id/review/pdf — stub for Phase 5
func (h *ReviewHandler) GeneratePDF(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "pdf not implemented yet — coming in Phase 5"})
}
+59
View File
@@ -0,0 +1,59 @@
package handlers
import (
"database/sql"
"net/http"
"github.com/gin-gonic/gin"
)
type SettingsHandler struct {
db *sql.DB
}
func NewSettingsHandler(db *sql.DB) *SettingsHandler {
return &SettingsHandler{db: db}
}
func (h *SettingsHandler) GetSettings(c *gin.Context) {
settings := make(map[string]string)
rows, err := h.db.Query(`SELECT key, value FROM settings`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer rows.Close()
for rows.Next() {
var k, v string
if rows.Scan(&k, &v) == nil {
settings[k] = v
}
}
c.JSON(http.StatusOK, settings)
}
func (h *SettingsHandler) UpdateSettings(c *gin.Context) {
var body map[string]string
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
tx, err := h.db.Begin()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
for key, val := range body {
_, err := tx.Exec(`INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)`, key, val)
if err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
if err := tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+98
View File
@@ -0,0 +1,98 @@
package main
import (
"fmt"
"html/template"
"log"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"github.com/HoHD/PR-Helper/config"
"github.com/HoHD/PR-Helper/database"
"github.com/HoHD/PR-Helper/handlers"
)
func main() {
cfg := config.Load()
db, err := database.New(cfg.DBPath())
if err != nil {
log.Fatalf("failed to initialize database: %v", err)
}
defer db.Close()
gin.SetMode(cfg.GinMode)
r := gin.Default()
// Custom template functions
funcMap := template.FuncMap{
"add": func(a, b int) int { return a + b },
"formatSize": func(bytes int64) string {
if bytes < 1024 {
return fmt.Sprintf("%d B", bytes)
}
if bytes < 1024*1024 {
return fmt.Sprintf("%.1f KB", float64(bytes)/1024)
}
return fmt.Sprintf("%.1f MB", float64(bytes)/1024/1024)
},
}
// Load templates: base layout first, then pages and partials
tmpl := template.New("").Funcs(funcMap)
// Walk all template files and parse them together
filepath.Walk("templates", func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".html") {
return nil
}
data, readErr := os.ReadFile(path)
if readErr != nil {
return readErr
}
// Use the relative path as template name (e.g. "layouts/base.html")
name := strings.TrimPrefix(path, "templates/")
tmpl, _ = tmpl.New(name).Parse(string(data))
return nil
})
r.SetHTMLTemplate(tmpl)
// Static files
r.Static("/static", "./static")
// Handlers
pages := handlers.NewPageHandler(db.Conn())
settings := handlers.NewSettingsHandler(db.Conn())
repos := handlers.NewReposHandler(db.Conn(), cfg.ReposDir())
generate := handlers.NewGenerateHandler(db.Conn())
review := handlers.NewReviewHandler(db.Conn())
// Page routes
r.GET("/", pages.Index)
r.GET("/repo/:id", pages.Repo)
r.GET("/repo/:id/generate", pages.Generate)
r.GET("/repo/:id/review", pages.Review)
r.GET("/settings", pages.Settings)
// API routes
r.GET("/api/settings", settings.GetSettings)
r.PUT("/api/settings", settings.UpdateSettings)
r.GET("/api/repos", repos.ListRepos)
r.POST("/api/repos", repos.CloneRepo)
r.DELETE("/api/repos/:id", repos.DeleteRepo)
r.POST("/api/repos/:id/cleanup", repos.CleanupRepos)
r.GET("/api/repos/:id/graph", repos.GetGraph)
r.GET("/api/repos/:id/diff", repos.GetDiff)
r.POST("/api/repos/:id/generate", generate.Generate)
r.POST("/api/repos/:id/review", review.Review)
r.POST("/api/repos/:id/review/notes", review.SaveNotes)
r.GET("/api/repos/:id/review/notes", review.GetNotes)
r.POST("/api/repos/:id/review/pdf", review.GeneratePDF)
log.Printf("PR-Helper starting on :%s", cfg.Port)
if err := r.Run(":" + cfg.Port); err != nil {
log.Fatalf("failed to start server: %v", err)
}
}
+23
View File
@@ -0,0 +1,23 @@
package models
import "time"
type Analysis struct {
ID int64 `json:"id"`
RepoID int64 `json:"repo_id"`
Type string `json:"type"` // "pr_description" | "code_review"
BaseRef string `json:"base_ref"`
HeadRef string `json:"head_ref"`
Result string `json:"result"`
CreatedAt time.Time `json:"created_at"`
}
type ReviewNote struct {
ID int64 `json:"id"`
AnalysisID int64 `json:"analysis_id"`
Scope string `json:"scope"` // "overall" | "file" | "suggestion"
ScopeKey string `json:"scope_key"` // empty for overall, filename for file, suggestion ID for suggestion
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+12
View File
@@ -0,0 +1,12 @@
package models
import "time"
type Repository struct {
ID int64 `json:"id"`
URL string `json:"url"`
LocalPath string `json:"local_path"`
SizeBytes int64 `json:"size_bytes"`
ClonedAt time.Time `json:"cloned_at"`
LastUsed time.Time `json:"last_used"`
}
+10
View File
@@ -0,0 +1,10 @@
package models
var DefaultSettings = map[string]string{
"llm.endpoint": "https://api.openai.com/v1",
"llm.api_key": "",
"llm.model": "gpt-4o",
"review.top_n": "20",
"cache.max_age_days": "7",
"cache.max_size_mb": "5000",
}
+61
View File
@@ -0,0 +1,61 @@
/* PR-Helper Custom Styles */
/* Prose styling for rendered content */
.prose {
line-height: 1.7;
}
.prose h1, .prose h2, .prose h3 {
font-weight: 600;
margin-top: 1.5em;
margin-bottom: 0.5em;
}
.prose h1 { font-size: 1.5em; }
.prose h2 { font-size: 1.25em; }
.prose h3 { font-size: 1.1em; }
.prose p { margin-bottom: 1em; }
.prose ul, .prose ol {
margin-left: 1.5em;
margin-bottom: 1em;
}
.prose li { margin-bottom: 0.25em; }
.prose code {
background: #f3f4f6;
padding: 0.15em 0.4em;
border-radius: 4px;
font-size: 0.9em;
}
.prose pre {
background: #1f2937;
color: #e5e7eb;
padding: 1em;
border-radius: 8px;
overflow-x: auto;
margin-bottom: 1em;
}
.prose pre code {
background: none;
padding: 0;
color: inherit;
}
.prose blockquote {
border-left: 4px solid #d1d5db;
padding-left: 1em;
color: #6b7280;
margin-bottom: 1em;
}
/* Severity badge colors (used by review) */
.border-red-400 { border-color: #f87171; }
.border-yellow-400 { border-color: #facc15; }
.border-green-400 { border-color: #4ade80; }
.bg-red-50 { background-color: #fef2f2; }
.bg-yellow-50 { background-color: #fefce8; }
.bg-green-50 { background-color: #f0fdf4; }
/* Loading spinner */
@keyframes spin {
to { transform: rotate(360deg); }
}
.animate-spin {
animation: spin 1s linear infinite;
}
+1
View File
File diff suppressed because one or more lines are too long
+2
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1213
View File
File diff suppressed because one or more lines are too long
+32
View File
@@ -0,0 +1,32 @@
{{define "head"}}
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PR-Helper</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/static/css/style.css">
<script src="/static/js/htmx.min.js"></script>
</head>
{{end}}
{{define "nav"}}
<nav class="bg-white shadow-sm border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-14">
<div class="flex items-center space-x-8">
<a href="/" class="text-lg font-bold text-indigo-600 hover:text-indigo-700">🔀 PR-Helper</a>
<a href="/" class="text-sm text-gray-600 hover:text-gray-900">首页</a>
<a href="/settings" class="text-sm text-gray-600 hover:text-gray-900">设置</a>
</div>
</div>
</div>
</nav>
{{end}}
{{define "footer"}}
<footer class="bg-white border-t border-gray-200 py-4">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-sm text-gray-400">
PR-Helper — AI-powered PR description & code review
</div>
</footer>
{{end}}
+102
View File
@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="zh-CN" class="h-full">
{{template "head" .}}
<body class="h-full bg-gray-50 text-gray-900">
<div class="min-h-full flex flex-col">
{{template "nav" .}}
<main class="flex-1">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 class="text-2xl font-bold mb-6">生成 PR 描述</h1>
<!-- Ref Selection -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
<h2 class="text-lg font-semibold mb-4">选择范围</h2>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Base (基准)</label>
<input type="text" id="base-ref" placeholder="main"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Head (目标)</label>
<input type="text" id="head-ref" placeholder="feature/branch"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div>
</div>
<button id="generate-btn" onclick="generatePR()"
class="mt-4 bg-green-600 text-white px-5 py-2 rounded-md text-sm font-medium hover:bg-green-700">
生成 PR 描述
</button>
</div>
<!-- Output -->
<div id="pr-output" class="hidden bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold">PR 描述</h2>
<button onclick="copyMarkdown()" class="text-sm text-indigo-600 hover:text-indigo-800">📋 复制 Markdown</button>
</div>
<div id="pr-title" class="text-xl font-bold mb-2"></div>
<div id="pr-type" class="inline-block bg-indigo-100 text-indigo-700 text-xs px-2 py-1 rounded mb-4"></div>
<div id="pr-summary" class="text-gray-700 mb-4"></div>
<div id="pr-details" class="prose prose-sm max-w-none"></div>
<div id="pr-impact" class="mt-4 p-3 bg-yellow-50 rounded text-sm text-yellow-800"></div>
</div>
</div>
</main>
{{template "footer" .}}
</div>
<script>
async function generatePR() {
const base = document.getElementById('base-ref').value.trim();
const head = document.getElementById('head-ref').value.trim();
if (!base || !head) { alert('请填写 base 和 head'); return; }
const btn = document.getElementById('generate-btn');
const output = document.getElementById('pr-output');
btn.disabled = true; btn.textContent = '生成中...';
output.classList.remove('hidden');
try {
const resp = await fetch('/api/repos/{{.ID}}/generate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({base, head})
});
if (!resp.ok) { const data = await resp.json(); throw new Error(data.error || '生成失败'); }
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '', eventType = '';
while (true) {
const {done, value} = await reader.read();
if (done) break;
buffer += decoder.decode(value, {stream: true});
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('event: ')) eventType = line.slice(7).trim();
else if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
if (eventType === 'title') document.getElementById('pr-title').textContent = data.content;
if (eventType === 'type') document.getElementById('pr-type').textContent = data.content;
if (eventType === 'summary') document.getElementById('pr-summary').textContent = data.content;
if (eventType === 'detail') document.getElementById('pr-details').innerHTML = data.content;
if (eventType === 'impact') document.getElementById('pr-impact').textContent = '影响范围: ' + data.content;
} catch {}
}
}
}
} catch (err) {
document.getElementById('pr-summary').textContent = '错误: ' + err.message;
} finally {
btn.disabled = false; btn.textContent = '生成 PR 描述';
}
}
function copyMarkdown() {
const title = document.getElementById('pr-title').textContent;
const type = document.getElementById('pr-type').textContent;
const summary = document.getElementById('pr-summary').textContent;
const details = document.getElementById('pr-details').innerText;
const impact = document.getElementById('pr-impact').textContent;
const md = '# ' + title + '\n\n**Type:** ' + type + '\n\n## Summary\n' + summary + '\n\n## Details\n' + details + '\n\n## Impact\n' + impact;
navigator.clipboard.writeText(md);
}
</script>
</body>
</html>
+120
View File
@@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="zh-CN" class="h-full">
{{template "head" .}}
<body class="h-full bg-gray-50 text-gray-900">
<div class="min-h-full flex flex-col">
{{template "nav" .}}
<main class="flex-1">
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Hero -->
<div class="text-center mb-10">
<h1 class="text-3xl font-bold text-gray-900 mb-3">PR-Helper</h1>
<p class="text-gray-500">输入 Git 仓库 URL,自动生成 PR 描述并进行 AI 代码审查</p>
</div>
<!-- Clone Form -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
<h2 class="text-lg font-semibold mb-4">克隆仓库</h2>
<form id="clone-form" class="flex gap-3">
<input type="text" id="repo-url" name="url" placeholder="https://github.com/user/repo.git"
class="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" required>
<button type="submit" id="clone-btn"
class="bg-indigo-600 text-white px-5 py-2 rounded-md text-sm font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
克隆
</button>
</form>
<div id="clone-progress" class="hidden mt-4">
<div class="flex items-center gap-3 mb-2">
<div class="animate-spin rounded-full h-4 w-4 border-2 border-indigo-600 border-t-transparent"></div>
<span id="clone-status" class="text-sm text-gray-600">准备中...</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div id="clone-bar" class="bg-indigo-600 h-2 rounded-full transition-all duration-300" style="width:0%"></div>
</div>
</div>
<div id="clone-error" class="hidden mt-3 text-sm text-red-600"></div>
</div>
<!-- Cached Repos -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold">已缓存仓库</h2>
<button hx-post="/api/repos/cleanup" hx-swap="none"
class="text-sm text-gray-500 hover:text-red-600"
hx-on::after-request="window.location.reload()">
清理过期缓存
</button>
</div>
<div id="repos-list">
{{if .Repos}}
<div class="divide-y divide-gray-100">
{{range .Repos}}
<div class="py-3 flex items-center justify-between">
<div>
<a href="/repo/{{.ID}}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800">{{.URL}}</a>
<p class="text-xs text-gray-400 mt-1">最后使用: {{.LastUsed}}</p>
</div>
<button hx-delete="/api/repos/{{.ID}}" hx-confirm="确定删除此仓库缓存?"
hx-swap="none" hx-on::after-request="window.location.reload()"
class="text-xs text-red-500 hover:text-red-700">删除</button>
</div>
{{end}}
</div>
{{else}}
<p class="text-sm text-gray-400 text-center py-6">暂无缓存仓库</p>
{{end}}
</div>
</div>
</div>
</main>
{{template "footer" .}}
</div>
<script>
document.getElementById('clone-form').addEventListener('submit', async function(e) {
e.preventDefault();
const url = document.getElementById('repo-url').value.trim();
if (!url) return;
const btn = document.getElementById('clone-btn');
const progress = document.getElementById('clone-progress');
const status = document.getElementById('clone-status');
const bar = document.getElementById('clone-bar');
const errorEl = document.getElementById('clone-error');
btn.disabled = true;
progress.classList.remove('hidden');
errorEl.classList.add('hidden');
bar.style.width = '0%';
try {
const resp = await fetch('/api/repos', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({url: url})
});
if (!resp.ok) { const data = await resp.json(); throw new Error(data.error || 'clone failed'); }
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const {done, value} = await reader.read();
if (done) break;
buffer += decoder.decode(value, {stream: true});
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
if (data.progress !== undefined) bar.style.width = data.progress + '%';
if (data.message) status.textContent = data.message;
if (data.done) { window.location.href = '/repo/' + data.repo_id; return; }
} catch {}
}
}
}
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('hidden');
} finally {
btn.disabled = false;
}
});
</script>
</body>
</html>
+51
View File
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="zh-CN" class="h-full">
{{template "head" .}}
<body class="h-full bg-gray-50 text-gray-900">
<div class="min-h-full flex flex-col">
{{template "nav" .}}
<main class="flex-1">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">仓库详情</h1>
<div class="flex gap-3">
<a href="/repo/{{.ID}}/generate"
class="bg-green-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-green-700">
生成 PR 描述
</a>
<a href="/repo/{{.ID}}/review"
class="bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-blue-700">
AI 代码审查
</a>
</div>
</div>
<!-- Repo Info -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
<div id="repo-info" class="text-sm text-gray-500">加载中...</div>
</div>
<!-- Git Graph -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h2 class="text-lg font-semibold mb-4">Git Graph</h2>
<div id="git-graph" class="overflow-auto" style="min-height: 400px;">
<p class="text-sm text-gray-400 text-center py-10">Git Graph 将在 Phase 3 实现</p>
</div>
</div>
</div>
</main>
{{template "footer" .}}
</div>
<script>
fetch('/api/repos').then(r => r.json()).then(repos => {
const repo = repos.find(r => r.id == {{.ID}});
const el = document.getElementById('repo-info');
if (repo) {
el.innerHTML = '<p><strong>URL:</strong> ' + repo.url + '</p>' +
'<p class="mt-1"><strong>大小:</strong> ' + (repo.size_bytes / 1024 / 1024).toFixed(1) + ' MB</p>' +
'<p class="mt-1"><strong>克隆时间:</strong> ' + repo.cloned_at + '</p>';
} else {
el.innerHTML = '<p class="text-red-500">仓库未找到</p>';
}
});
</script>
</body>
</html>
+113
View File
@@ -0,0 +1,113 @@
<!DOCTYPE html>
<html lang="zh-CN" class="h-full">
{{template "head" .}}
<body class="h-full bg-gray-50 text-gray-900">
<div class="min-h-full flex flex-col">
{{template "nav" .}}
<main class="flex-1">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 class="text-2xl font-bold mb-6">AI 代码审查</h1>
<!-- Ref Selection + Top-N -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
<h2 class="text-lg font-semibold mb-4">审查配置</h2>
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Base</label>
<input type="text" id="base-ref" placeholder="main"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Head</label>
<input type="text" id="head-ref" placeholder="feature/branch"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Top-N 文件数</label>
<input type="number" id="top-n" value="{{.TopN}}" min="0"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
<p class="text-xs text-gray-400 mt-1">0 = 分析全部文件</p>
</div>
</div>
<button id="review-btn" onclick="startReview()"
class="mt-4 bg-blue-600 text-white px-5 py-2 rounded-md text-sm font-medium hover:bg-blue-700">
开始审查
</button>
</div>
<!-- Review Output -->
<div id="review-output" class="hidden space-y-6">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h2 class="text-lg font-semibold mb-3">整体评估</h2>
<div id="review-summary" class="prose prose-sm max-w-none text-gray-700"></div>
</div>
<div id="file-reviews" class="space-y-4"></div>
</div>
</div>
</main>
{{template "footer" .}}
</div>
<script>
async function startReview() {
const base = document.getElementById('base-ref').value.trim();
const head = document.getElementById('head-ref').value.trim();
const topN = parseInt(document.getElementById('top-n').value) || 0;
if (!base || !head) { alert('请填写 base 和 head'); return; }
const btn = document.getElementById('review-btn');
const output = document.getElementById('review-output');
const fileReviews = document.getElementById('file-reviews');
btn.disabled = true; btn.textContent = '审查中...';
output.classList.remove('hidden');
fileReviews.innerHTML = '';
document.getElementById('review-summary').innerHTML = '';
let currentFileEl = null;
try {
const resp = await fetch('/api/repos/{{.ID}}/review', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({base, head, top_n: topN})
});
if (!resp.ok) { const data = await resp.json(); throw new Error(data.error || '审查失败'); }
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '', eventType = '';
while (true) {
const {done, value} = await reader.read();
if (done) break;
buffer += decoder.decode(value, {stream: true});
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('event: ')) eventType = line.slice(7).trim();
else if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
if (eventType === 'file_start') {
currentFileEl = document.createElement('div');
currentFileEl.className = 'bg-white rounded-lg shadow-sm border border-gray-200 p-6';
currentFileEl.innerHTML = '<h3 class="font-semibold text-gray-900 mb-3">📄 ' + data.file + '</h3><div class="space-y-2"></div>';
fileReviews.appendChild(currentFileEl);
}
if (eventType === 'suggestion' && currentFileEl) {
const colors = {critical: 'red', warning: 'yellow', info: 'green'};
const icons = {critical: '🔴', warning: '🟡', info: '🟢'};
const c = colors[data.severity] || 'gray';
const icon = icons[data.severity] || '⚪';
const div = document.createElement('div');
div.className = 'border-l-4 border-' + c + '-400 bg-' + c + '-50 px-4 py-2 rounded-r text-sm';
div.innerHTML = '<span class="font-medium">' + icon + ' ' + data.severity + '</span><p class="mt-1 text-gray-700">' + data.content + '</p>';
currentFileEl.querySelector('.space-y-2').appendChild(div);
}
if (eventType === 'file_end') currentFileEl = null;
if (eventType === 'summary') document.getElementById('review-summary').innerHTML = data.content;
} catch {}
}
}
}
} catch (err) {
document.getElementById('review-summary').textContent = '错误: ' + err.message;
} finally {
btn.disabled = false; btn.textContent = '开始审查';
}
}
</script>
</body>
</html>
+96
View File
@@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="zh-CN" class="h-full">
{{template "head" .}}
<body class="h-full bg-gray-50 text-gray-900">
<div class="min-h-full flex flex-col">
{{template "nav" .}}
<main class="flex-1">
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 class="text-2xl font-bold mb-6">设置</h1>
<form id="settings-form" class="space-y-6">
<!-- LLM Settings -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h2 class="text-lg font-semibold mb-4">LLM 配置</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">API Endpoint</label>
<input type="text" name="llm.endpoint" value="{{index .Settings "llm.endpoint"}}"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">API Key</label>
<input type="password" name="llm.api_key" value="{{index .Settings "llm.api_key"}}"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" placeholder="sk-...">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Model</label>
<input type="text" name="llm.model" value="{{index .Settings "llm.model"}}"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div>
</div>
</div>
<!-- Review Settings -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h2 class="text-lg font-semibold mb-4">审查配置</h2>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Top-N 文件数</label>
<p class="text-xs text-gray-400 mb-2">大 diff 时优先分析变更最大的 N 个文件,设为 0 分析全部</p>
<input type="number" name="review.top_n" value="{{index .Settings "review.top_n"}}" min="0"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div>
</div>
<!-- Cache Settings -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h2 class="text-lg font-semibold mb-4">缓存配置</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">缓存过期天数</label>
<input type="number" name="cache.max_age_days" value="{{index .Settings "cache.max_age_days"}}" min="1"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">最大缓存大小 (MB)</label>
<input type="number" name="cache.max_size_mb" value="{{index .Settings "cache.max_size_mb"}}" min="100"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div>
</div>
</div>
<!-- Submit -->
<div class="flex items-center gap-3">
<button type="submit"
class="bg-indigo-600 text-white px-5 py-2 rounded-md text-sm font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
保存设置
</button>
<span id="save-status" class="text-sm hidden"></span>
</div>
</form>
</div>
</main>
{{template "footer" .}}
</div>
<script>
document.getElementById('settings-form').addEventListener('submit', async function(e) {
e.preventDefault();
const form = new FormData(this);
const data = {};
for (const [key, val] of form.entries()) data[key] = val;
const status = document.getElementById('save-status');
try {
const resp = await fetch('/api/settings', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
if (!resp.ok) throw new Error('保存失败');
status.textContent = '✓ 已保存';
status.className = 'text-sm text-green-600';
} catch (err) {
status.textContent = '✗ ' + err.message;
status.className = 'text-sm text-red-600';
}
status.classList.remove('hidden');
setTimeout(() => status.classList.add('hidden'), 3000);
});
</script>
</body>
</html>