feat: Phase 4 — LLM 集成,PR 描述生成与 AI 代码审查
This commit is contained in:
@@ -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 支持,三级作用域)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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:]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user