feat: Phase 4 — LLM 集成,PR 描述生成与 AI 代码审查

This commit is contained in:
2026-06-19 19:43:18 +08:00
parent 7ac3a3726b
commit afb270721c
8 changed files with 783 additions and 29 deletions
+7 -7
View File
@@ -713,12 +713,12 @@ volumes:
## 10. 开发阶段
> **当前进度**:Phase 1-3 已完成,Phase 4-6 待开发
> **当前进度**:Phase 1-4 已完成,Phase 5-6 待开发
>
> - ✅ Phase 1(基础骨架):Go 项目、Gin 路由、SQLite、设置页面
> - ✅ Phase 2(Git 核心):clone、refs、diff、graph 数据、缓存管理
> - ✅ Phase 3(前端交互):Git Graph、Diff 查看器、文件树、SSE 流式展示、行内建议
> - ⏳ Phase 4(LLM 集成):待开发
> - ✅ Phase 4(LLM 集成):OpenAI 兼容 API、PR 描述生成、AI 代码审查、Top-N 策略
> - ⏳ Phase 5(审查编辑与导出):待开发
> - ⚠️ Phase 6(完善与部署):Docker 配置已完成,其他待完善
@@ -745,11 +745,11 @@ volumes:
- [x] AI Review 行内建议展示(嵌入代码行旁)
- [x] SSE 流式展示组件(sse.js)
### Phase 4:LLM 集成 ⏳ 待开发
- [ ] OpenAI 兼容 API 调用封装(services/llm.go)
- [ ] PR 描述生成(prompt + SSE 流式)(services/generate.go)
- [ ] AI 代码审查(分文件处理 + 汇总)(services/review.go)
- [ ] 大 diff 处理(Top-N 策略)
### Phase 4:LLM 集成 ✅ 已完成
- [x] OpenAI 兼容 API 调用封装(services/llm.go)
- [x] PR 描述生成(prompt + SSE 流式)(services/generate.go)
- [x] AI 代码审查(分文件处理 + 汇总)(services/review.go)
- [x] 大 diff 处理(Top-N 策略)
### Phase 5:审查编辑与导出 ⏳ 待开发
- [ ] 备注编辑器组件(Markdown 支持,三级作用域)
+2 -1
View File
@@ -4,7 +4,9 @@ go 1.25.0
require (
github.com/gin-gonic/gin v1.10.0
github.com/go-git/go-git/v5 v5.19.1
github.com/mattn/go-sqlite3 v1.14.24
github.com/sashabaranov/go-openai v1.41.2
)
require (
@@ -22,7 +24,6 @@ require (
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.9.0 // indirect
github.com/go-git/go-git/v5 v5.19.1 // 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
+30 -17
View File
@@ -5,6 +5,10 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
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=
@@ -20,6 +24,8 @@ github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1
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/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
@@ -28,10 +34,14 @@ 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/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00=
github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@@ -46,8 +56,8 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
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/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
@@ -56,14 +66,16 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
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/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
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/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=
@@ -75,13 +87,20 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
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/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM=
github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
@@ -99,8 +118,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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=
@@ -111,13 +131,11 @@ golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUu
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.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
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/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
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/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -126,27 +144,22 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
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/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
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/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+61 -2
View File
@@ -2,8 +2,11 @@ package handlers
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"github.com/HoHD/PR-Helper/services"
"github.com/gin-gonic/gin"
)
@@ -15,7 +18,63 @@ func NewGenerateHandler(db *sql.DB) *GenerateHandler {
return &GenerateHandler{db: db}
}
// Generate handles POST /api/repos/:id/generate — stub for Phase 4
// Generate handles POST /api/repos/:id/generate — SSE streaming PR description generation.
func (h *GenerateHandler) Generate(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "generate not implemented yet — coming in Phase 4"})
id := c.Param("id")
// Get repo info
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
}
// Parse request
var req struct {
Base string `json:"base" binding:"required"`
Head string `json:"head" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "base and head are required"})
return
}
// Set SSE headers
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
c.Status(http.StatusOK)
flusher, ok := c.Writer.(http.Flusher)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "streaming not supported"})
return
}
sendEvent := func(event string, data interface{}) {
jsonData, _ := json.Marshal(data)
fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, jsonData)
flusher.Flush()
}
// Update last_used
h.db.Exec(`UPDATE repositories SET last_used = datetime('now') WHERE id = ?`, id)
// Generate PR description
pr, err := services.GeneratePR(h.db, localPath, req.Base, req.Head, sendEvent)
if err != nil {
sendEvent("error", map[string]interface{}{"message": err.Error()})
return
}
// Save analysis to DB
resultJSON, _ := json.Marshal(pr)
h.db.Exec(`INSERT INTO analyses (repo_id, type, base_ref, head_ref, result) VALUES (?, 'pr_description', ?, ?, ?)`,
id, req.Base, req.Head, string(resultJSON))
}
+82 -2
View File
@@ -2,8 +2,12 @@ package handlers
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/HoHD/PR-Helper/services"
"github.com/gin-gonic/gin"
)
@@ -15,9 +19,85 @@ func NewReviewHandler(db *sql.DB) *ReviewHandler {
return &ReviewHandler{db: db}
}
// Review handles POST /api/repos/:id/review — stub for Phase 4
// Review handles POST /api/repos/:id/review — SSE streaming AI code review.
func (h *ReviewHandler) Review(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "review not implemented yet — coming in Phase 4"})
id := c.Param("id")
// Get repo info
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
}
// Parse request
var req struct {
Base string `json:"base" binding:"required"`
Head string `json:"head" binding:"required"`
TopN *int `json:"top_n"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "base and head are required"})
return
}
// Determine Top-N: request value > settings default (20)
topN := 20
if req.TopN != nil {
topN = *req.TopN
} else {
var topNStr string
h.db.QueryRow(`SELECT value FROM settings WHERE key = 'review.top_n'`).Scan(&topNStr)
if topNStr != "" {
if n, err := strconv.Atoi(topNStr); err == nil && n > 0 {
topN = n
}
}
}
// Set SSE headers
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
c.Status(http.StatusOK)
flusher, ok := c.Writer.(http.Flusher)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "streaming not supported"})
return
}
sendEvent := func(event string, data interface{}) {
jsonData, _ := json.Marshal(data)
fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, jsonData)
flusher.Flush()
}
// Update last_used
h.db.Exec(`UPDATE repositories SET last_used = datetime('now') WHERE id = ?`, id)
// Run AI review
err = services.GenerateReview(h.db, localPath, req.Base, req.Head, topN, sendEvent)
if err != nil {
sendEvent("error", map[string]interface{}{"message": err.Error()})
return
}
// Save analysis to DB
resultData := map[string]interface{}{
"base": req.Base,
"head": req.Head,
"top_n": topN,
}
resultJSON, _ := json.Marshal(resultData)
h.db.Exec(`INSERT INTO analyses (repo_id, type, base_ref, head_ref, result) VALUES (?, 'code_review', ?, ?, ?)`,
id, req.Base, req.Head, string(resultJSON))
}
// SaveNotes handles POST /api/repos/:id/review/notes — stub for Phase 5
+151
View File
@@ -0,0 +1,151 @@
package services
import (
"database/sql"
"encoding/json"
"fmt"
"strings"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
goopenai "github.com/sashabaranov/go-openai"
)
// PRDescription is the structured output from PR description generation.
type PRDescription struct {
Title string `json:"title"`
Type string `json:"type"`
Summary string `json:"summary"`
Details string `json:"details"`
Impact string `json:"impact"`
}
// GeneratePR generates a structured PR description from commit history and diff.
// It streams progress via the callback and returns the parsed PR description.
func GeneratePR(db *sql.DB, repoPath, base, head string, callback StreamCallback) (*PRDescription, error) {
// Read LLM config
config, err := GetLLMConfig(db)
if err != nil {
return nil, err
}
// Open repo
repo, err := OpenRepo(repoPath)
if err != nil {
return nil, fmt.Errorf("open repo: %w", err)
}
// Get commits between base and head
commits, err := GetCommitLog(repo, head, 100)
if err != nil {
return nil, fmt.Errorf("get commits: %w", err)
}
// Filter commits to only those reachable from head but not from base
baseHash, err := repo.ResolveRevision(plumbing.Revision(base))
if err == nil {
baseCommit, bcErr := repo.CommitObject(*baseHash)
if bcErr == nil {
baseSet := make(map[string]bool)
baseQueue := []*object.Commit{baseCommit}
for len(baseQueue) > 0 {
c := baseQueue[0]
baseQueue = baseQueue[1:]
if baseSet[c.Hash.String()] {
continue
}
baseSet[c.Hash.String()] = true
for _, p := range c.ParentHashes {
pc, err := repo.CommitObject(p)
if err == nil {
baseQueue = append(baseQueue, pc)
}
}
}
var filtered []CommitInfo
for _, ci := range commits {
if !baseSet[ci.Hash] {
filtered = append(filtered, ci)
}
}
if len(filtered) > 0 {
commits = filtered
}
}
}
// Get diff
diff, err := GetDiff(repo, base, head)
if err != nil {
return nil, fmt.Errorf("get diff: %w", err)
}
// Build prompt
var commitLines []string
for _, c := range commits {
commitLines = append(commitLines, fmt.Sprintf("- %s %s", c.ShortHash, c.Message))
}
commitStr := strings.Join(commitLines, "\n")
if commitStr == "" {
commitStr = "(no commits)"
}
// Truncate diff if too large (approx 60k chars to stay within token limits)
if len(diff) > 60000 {
diff = diff[:60000] + "\n\n... [diff truncated due to size]"
}
prompt := fmt.Sprintf(`你是一个专业的技术文档撰写助手。根据以下 Git 变更信息,生成一份结构化的 PR 描述。
## Commit 记录
%s
## 代码变更 (Diff)
%s
请按以下 JSON 格式输出(直接输出 JSON,不要包含 markdown 代码块标记):
{
"title": "简洁的 PR 标题",
"type": "变更类型: feat|fix|refactor|docs|chore|style|test|perf",
"summary": "一段话概述变更内容",
"details": "详细的变更说明,按模块分组,使用 Markdown 格式",
"impact": "影响范围说明"
}`, commitStr, diff)
messages := []goopenai.ChatCompletionMessage{
{Role: goopenai.ChatMessageRoleUser, Content: prompt},
}
// Call LLM with streaming
fullResponse, err := ChatStream(config, messages, callback)
if err != nil {
return nil, fmt.Errorf("LLM call: %w", err)
}
// Parse JSON response
jsonStr := extractJSON(fullResponse)
var pr PRDescription
if err := json.Unmarshal([]byte(jsonStr), &pr); err != nil {
return nil, fmt.Errorf("parse PR description: %w (raw: %s)", err, truncateString(fullResponse, 200))
}
// Send structured events
if callback != nil {
callback("title", map[string]interface{}{"content": pr.Title})
callback("type", map[string]interface{}{"content": pr.Type})
callback("summary", map[string]interface{}{"content": pr.Summary})
callback("detail", map[string]interface{}{"content": pr.Details})
callback("impact", map[string]interface{}{"content": pr.Impact})
callback("done", map[string]interface{}{"content": ""})
}
return &pr, nil
}
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
+152
View File
@@ -0,0 +1,152 @@
package services
import (
"context"
"database/sql"
"fmt"
"io"
"strings"
goopenai "github.com/sashabaranov/go-openai"
)
// LLMConfig holds LLM API configuration.
type LLMConfig struct {
Endpoint string
APIKey string
Model string
}
// GetLLMConfig reads LLM settings from the database.
func GetLLMConfig(db *sql.DB) (LLMConfig, error) {
config := LLMConfig{}
rows, err := db.Query(`SELECT key, value FROM settings WHERE key IN ('llm.endpoint', 'llm.api_key', 'llm.model')`)
if err != nil {
return config, fmt.Errorf("read settings: %w", err)
}
defer rows.Close()
for rows.Next() {
var key, value string
if rows.Scan(&key, &value) == nil {
switch key {
case "llm.endpoint":
config.Endpoint = value
case "llm.api_key":
config.APIKey = value
case "llm.model":
config.Model = value
}
}
}
if config.APIKey == "" {
return config, fmt.Errorf("LLM API key not configured — please set it in the settings page")
}
if config.Model == "" {
config.Model = "gpt-4o"
}
return config, nil
}
// StreamCallback is called for each SSE event during LLM streaming.
type StreamCallback func(event string, data interface{})
// ChatStream sends a streaming chat completion request to an OpenAI-compatible API.
// It calls callback with "content" events for each chunk received.
// Returns the full concatenated response text.
func ChatStream(config LLMConfig, messages []goopenai.ChatCompletionMessage, callback StreamCallback) (string, error) {
clientConfig := goopenai.DefaultConfig(config.APIKey)
if config.Endpoint != "" {
clientConfig.BaseURL = config.Endpoint
}
client := goopenai.NewClientWithConfig(clientConfig)
ctx := context.Background()
stream, err := client.CreateChatCompletionStream(ctx, goopenai.ChatCompletionRequest{
Model: config.Model,
Messages: messages,
Stream: true,
})
if err != nil {
return "", fmt.Errorf("create stream: %w", err)
}
defer stream.Close()
var fullResponse strings.Builder
for {
response, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
return fullResponse.String(), fmt.Errorf("stream recv: %w", err)
}
if len(response.Choices) > 0 {
content := response.Choices[0].Delta.Content
if content != "" {
fullResponse.WriteString(content)
if callback != nil {
callback("content", map[string]interface{}{"content": content})
}
}
}
}
return fullResponse.String(), nil
}
// extractJSON attempts to extract a JSON object or array from a string
// that may contain markdown code blocks or extra text.
func extractJSON(s string) string {
// Try to find JSON in markdown code block
if idx := strings.Index(s, "```json"); idx >= 0 {
start := idx + 7
if end := strings.Index(s[start:], "```"); end >= 0 {
return strings.TrimSpace(s[start : start+end])
}
}
if idx := strings.Index(s, "```"); idx >= 0 {
start := idx + 3
if nl := strings.Index(s[start:], "\n"); nl >= 0 {
start += nl + 1
}
if end := strings.Index(s[start:], "```"); end >= 0 {
return strings.TrimSpace(s[start : start+end])
}
}
// Find first { or [
startObj := strings.Index(s, "{")
startArr := strings.Index(s, "[")
var start int
var endChar byte
if startObj >= 0 && (startArr < 0 || startObj < startArr) {
start = startObj
endChar = '}'
} else if startArr >= 0 {
start = startArr
endChar = ']'
} else {
return s
}
// Find matching closing bracket
depth := 0
for i := start; i < len(s); i++ {
if s[i] == '{' || s[i] == '[' {
depth++
} else if s[i] == '}' || s[i] == ']' {
depth--
if depth == 0 && s[i] == endChar {
return s[start : i+1]
}
}
}
return s[start:]
}
+298
View File
@@ -0,0 +1,298 @@
package services
import (
"database/sql"
"encoding/json"
"fmt"
"sort"
"strings"
goopenai "github.com/sashabaranov/go-openai"
)
// ReviewSuggestion is a single review finding for a file.
type ReviewSuggestion struct {
Severity string `json:"severity"`
Description string `json:"description"`
Suggestion string `json:"suggestion"`
CodeExample string `json:"code_example,omitempty"`
}
// FileReview holds the review results for one file.
type FileReview struct {
FileName string `json:"file_name"`
ChangeLines int `json:"change_lines"`
Suggestions []ReviewSuggestion `json:"suggestions"`
RawReview string `json:"raw_review"`
}
// ReviewSummary is the overall assessment after reviewing all files.
type ReviewSummary struct {
Score int `json:"score"`
Overall string `json:"overall"`
Findings string `json:"findings"`
Recommendations string `json:"recommendations"`
}
// countDiffLines counts the number of added/removed lines in a diff patch.
func countDiffLines(patch string) int {
count := 0
for _, line := range strings.Split(patch, "\n") {
if strings.HasPrefix(line, "+") || strings.HasPrefix(line, "-") {
if !strings.HasPrefix(line, "+++") && !strings.HasPrefix(line, "---") {
count++
}
}
}
return count
}
// GenerateReview performs AI code review on diff files with Top-N strategy.
// It streams events (file_start, suggestion, file_end, summary, done) via callback.
func GenerateReview(db *sql.DB, repoPath, base, head string, topN int, callback StreamCallback) error {
// Read LLM config
config, err := GetLLMConfig(db)
if err != nil {
return err
}
// Open repo
repo, err := OpenRepo(repoPath)
if err != nil {
return fmt.Errorf("open repo: %w", err)
}
// Get diff files
files, err := GetDiffFiles(repo, base, head)
if err != nil {
return fmt.Errorf("get diff files: %w", err)
}
if len(files) == 0 {
if callback != nil {
callback("summary", map[string]interface{}{"content": "没有检测到代码变更。"})
callback("done", map[string]interface{}{"content": ""})
}
return nil
}
// Sort by change size (descending)
sort.Slice(files, func(i, j int) bool {
return countDiffLines(files[i].Patch) > countDiffLines(files[j].Patch)
})
totalFiles := len(files)
// Apply Top-N (0 means analyze all)
if topN > 0 && topN < len(files) {
files = files[:topN]
}
reviewedFiles := len(files)
// Notify frontend of file count info
if callback != nil {
callback("progress", map[string]interface{}{
"total_files": totalFiles,
"reviewed_files": reviewedFiles,
"top_n": topN,
})
}
// Review each file
var fileReviews []FileReview
for i, file := range files {
if callback != nil {
callback("file_start", map[string]interface{}{
"file": file.Filename,
"index": i + 1,
"total": reviewedFiles,
})
}
changeLines := countDiffLines(file.Patch)
// Truncate per-file diff if too large
patch := file.Patch
if len(patch) > 30000 {
patch = patch[:30000] + "\n\n... [diff truncated due to size]"
}
// Build review prompt
prompt := fmt.Sprintf(`你是一个资深代码审查专家。请审查以下代码变更,给出专业的 Review 意见。
## 文件: %s
## 变更行数: +%d / -%d
## Diff
%s
请按以下 JSON 格式输出审查意见(直接输出 JSON 数组,不要包含 markdown 代码块标记):
[
{
"severity": "critical 或 warning 或 info",
"description": "问题描述",
"suggestion": "建议的修改方案",
"code_example": "建议的代码(如有)"
}
]
严重程度说明:
- critical: 严重问题(安全漏洞、数据丢失风险、崩溃风险)
- warning: 建议改进(性能问题、代码规范、可维护性)
- info: 提示信息(最佳实践、可选优化)
如果代码没有问题,输出空数组 []。
请用中文回复。`, file.Filename, changeLines/2+changeLines%2, changeLines/2, patch)
messages := []goopenai.ChatCompletionMessage{
{Role: goopenai.ChatMessageRoleUser, Content: prompt},
}
// Call LLM
fullResponse, err := ChatStream(config, messages, callback)
if err != nil {
// Continue with other files on error
if callback != nil {
callback("error", map[string]interface{}{
"file": file.Filename,
"message": err.Error(),
})
}
fileReviews = append(fileReviews, FileReview{
FileName: file.Filename,
ChangeLines: changeLines,
Suggestions: nil,
RawReview: fmt.Sprintf("Error: %s", err.Error()),
})
continue
}
// Parse suggestions
jsonStr := extractJSON(fullResponse)
var suggestions []ReviewSuggestion
if err := json.Unmarshal([]byte(jsonStr), &suggestions); err != nil {
// If parsing fails, try single object
var single ReviewSuggestion
if err2 := json.Unmarshal([]byte(jsonStr), &single); err2 == nil {
suggestions = []ReviewSuggestion{single}
} else {
// Fall back to raw text as info suggestion
suggestions = []ReviewSuggestion{{
Severity: "info",
Description: fullResponse,
}}
}
}
// Validate severity values
for i := range suggestions {
switch suggestions[i].Severity {
case "critical", "warning", "info":
// valid
default:
suggestions[i].Severity = "info"
}
}
// Send suggestion events
for _, s := range suggestions {
content := s.Description
if s.Suggestion != "" {
content += "\n\n**建议修改:** " + s.Suggestion
}
if s.CodeExample != "" {
content += "\n\n```\n" + s.CodeExample + "\n```"
}
if callback != nil {
callback("suggestion", map[string]interface{}{
"file": file.Filename,
"severity": s.Severity,
"content": content,
})
}
}
fileReviews = append(fileReviews, FileReview{
FileName: file.Filename,
ChangeLines: changeLines,
Suggestions: suggestions,
RawReview: fullResponse,
})
if callback != nil {
callback("file_end", map[string]interface{}{"file": file.Filename})
}
}
// Generate summary
if len(fileReviews) > 0 {
if callback != nil {
callback("progress", map[string]interface{}{"step": "generating_summary"})
}
var reviewParts []string
for _, fr := range fileReviews {
suggestionText := "没有发现问题"
if len(fr.Suggestions) > 0 {
var parts []string
for _, s := range fr.Suggestions {
parts = append(parts, fmt.Sprintf("[%s] %s", s.Severity, s.Description))
}
suggestionText = strings.Join(parts, "\n")
}
reviewParts = append(reviewParts, fmt.Sprintf("### %s (%d 行变更)\n%s",
fr.FileName, fr.ChangeLines, suggestionText))
}
summaryPrompt := fmt.Sprintf(`以下是多个文件的代码审查结果,请给出整体评估:
%s
请按以下 JSON 格式输出(直接输出 JSON,不要包含 markdown 代码块标记):
{
"score": 7,
"overall": "总体评价(2-3 句话)",
"findings": "按严重程度排序的主要发现汇总",
"recommendations": "改进建议优先级列表"
}
请用中文回复。`, strings.Join(reviewParts, "\n\n"))
messages := []goopenai.ChatCompletionMessage{
{Role: goopenai.ChatMessageRoleUser, Content: summaryPrompt},
}
summaryResponse, err := ChatStream(config, messages, callback)
if err == nil {
jsonStr := extractJSON(summaryResponse)
var summary ReviewSummary
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})
}
} else {
// If parsing fails, send raw response as summary
if callback != nil {
callback("summary", map[string]interface{}{"content": summaryResponse})
}
}
} else {
if callback != nil {
callback("error", map[string]interface{}{"message": "生成汇总失败: " + err.Error()})
}
}
}
if callback != nil {
callback("done", map[string]interface{}{"content": ""})
}
return nil
}