feat: Phase 2 — Git 核心功能,go-git 克隆、SSE 进度、D3.js 图形、diff2html 查看器

This commit is contained in:
2026-06-18 22:59:51 +08:00
parent 1e9b1a2129
commit 34ef868e10
11 changed files with 2024 additions and 247 deletions
+23 -6
View File
@@ -1,6 +1,6 @@
module github.com/HoHD/PR-Helper
go 1.22.2
go 1.25.0
require (
github.com/gin-gonic/gin v1.10.0
@@ -8,30 +8,47 @@ require (
)
require (
dario.cat/mergo v1.0.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
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
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/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/pjbgf/sha1cd v0.6.0 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/xanzy/ssh-agent v0.3.3 // 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
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+67
View File
@@ -1,20 +1,39 @@
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
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/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/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cloudwego/base64x v0.1.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/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
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/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=
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-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/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=
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=
@@ -25,15 +44,26 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
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/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/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=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
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/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/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=
@@ -47,13 +77,23 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/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/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/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=
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
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=
@@ -65,25 +105,52 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
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=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
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.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/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=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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/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/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=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
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=
+218 -11
View File
@@ -2,10 +2,15 @@ package handlers
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"github.com/HoHD/PR-Helper/services"
"github.com/gin-gonic/gin"
)
@@ -56,22 +61,18 @@ func (h *ReposHandler) DeleteRepo(c *gin.Context) {
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()})
@@ -92,22 +93,228 @@ func (h *ReposHandler) CleanupRepos(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"cleaned": cleaned})
}
// CloneRepo handles POST /api/repos — stub for Phase 2
// CloneRepo handles POST /api/repos with SSE progress events.
func (h *ReposHandler) CloneRepo(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "clone not implemented yet — coming in Phase 2"})
var req struct {
URL string `json:"url" binding:"required"`
Username string `json:"username"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "url is required"})
return
}
// GetGraph handles GET /api/repos/:id/graph — stub for Phase 2
// 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")
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()
}
sendEvent("start", map[string]interface{}{"url": req.URL})
// Generate unique directory name
repoName := filepath.Base(req.URL)
if repoName == "" || repoName == "." || repoName == "/" {
repoName = fmt.Sprintf("repo_%d", time.Now().UnixNano())
}
repoDir := filepath.Join(h.reposDir, fmt.Sprintf("%s_%d", repoName, time.Now().UnixNano()))
result, err := services.Clone(services.CloneOptions{
URL: req.URL,
Dir: repoDir,
Username: req.Username,
Password: req.Password,
}, func(event string, data interface{}) {
sendEvent(event, data)
})
if err != nil {
sendEvent("error", map[string]interface{}{"message": err.Error()})
return
}
// Save to database
now := time.Now().Format(time.RFC3339)
res, err := h.db.Exec(`INSERT INTO repositories (url, local_path, size_bytes, cloned_at, last_used) VALUES (?, ?, ?, ?, ?)`,
req.URL, repoDir, result.SizeBytes, now, now)
if err != nil {
sendEvent("error", map[string]interface{}{"message": "save to db: " + err.Error()})
return
}
repoID, _ := res.LastInsertId()
sendEvent("complete", map[string]interface{}{
"repo_id": repoID,
"size_bytes": result.SizeBytes,
})
}
// GetGraph handles GET /api/repos/:id/graph — returns D3.js-compatible data.
func (h *ReposHandler) GetGraph(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "graph not implemented yet — coming in Phase 2"})
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
}
// GetDiff handles GET /api/repos/:id/diff — stub for Phase 2
// Update last_used
h.db.Exec(`UPDATE repositories SET last_used = datetime('now') WHERE id = ?`, id)
repo, err := services.OpenRepo(localPath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "open repo: " + err.Error()})
return
}
maxCommits := 200
if mc := c.Query("max_commits"); mc != "" {
if n, err := strconv.Atoi(mc); err == nil && n > 0 {
maxCommits = n
}
}
graph, err := services.GetGraph(repo, maxCommits)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, graph)
}
// GetDiff handles GET /api/repos/:id/diff — returns unified diff or per-file diffs.
func (h *ReposHandler) GetDiff(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "diff not implemented yet — coming in Phase 2"})
id := c.Param("id")
base := c.Query("base")
head := c.Query("head")
if base == "" || head == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "base and head query params are required"})
return
}
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
}
// Update last_used
h.db.Exec(`UPDATE repositories SET last_used = datetime('now') WHERE id = ?`, id)
repo, err := services.OpenRepo(localPath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Check if per-file mode is requested
if c.Query("per_file") == "true" {
files, err := services.GetDiffFiles(repo, base, head)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, files)
return
}
diff, err := services.GetDiff(repo, base, head)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"diff": diff})
}
// GetRefs handles GET /api/repos/:id/refs — returns branches and tags.
func (h *ReposHandler) GetRefs(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
}
repo, err := services.OpenRepo(localPath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
refs, err := services.GetRefs(repo)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, refs)
}
// GetCommits handles GET /api/repos/:id/commits — returns commit log for a ref.
func (h *ReposHandler) GetCommits(c *gin.Context) {
id := c.Param("id")
refName := c.Query("ref")
if refName == "" {
refName = "HEAD"
}
maxCommits := 100
if mc := c.Query("limit"); mc != "" {
if n, err := strconv.Atoi(mc); err == nil && n > 0 {
maxCommits = n
}
}
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
}
commits, err := services.GetBranchCommits(localPath, refName, maxCommits)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, commits)
}
// 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 {
+2
View File
@@ -84,6 +84,8 @@ func main() {
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/refs", repos.GetRefs)
r.GET("/api/repos/:id/commits", repos.GetCommits)
r.GET("/api/repos/:id/diff", repos.GetDiff)
r.POST("/api/repos/:id/generate", generate.Generate)
r.POST("/api/repos/:id/review", review.Review)
+143
View File
@@ -0,0 +1,143 @@
package services
import (
"fmt"
"os"
"path/filepath"
"time"
)
// CacheCleanupResult holds the outcome of a cleanup operation.
type CacheCleanupResult struct {
RemovedCount int `json:"removed_count"`
FreedBytes int64 `json:"freed_bytes"`
RemovedPaths []string `json:"removed_paths"`
}
type repoEntry struct {
name string
path string
modTime time.Time
size int64
}
// CleanupExpiredRepos removes repos older than maxAge days from reposDir.
func CleanupExpiredRepos(reposDir string, maxAgeDays int) (*CacheCleanupResult, error) {
result := &CacheCleanupResult{}
cutoff := time.Now().AddDate(0, 0, -maxAgeDays)
entries, err := os.ReadDir(reposDir)
if err != nil {
if os.IsNotExist(err) {
return result, nil
}
return nil, fmt.Errorf("read repos dir: %w", err)
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
if info.ModTime().Before(cutoff) {
repoPath := filepath.Join(reposDir, entry.Name())
size := dirSize(repoPath)
if err := os.RemoveAll(repoPath); err != nil {
continue
}
result.RemovedCount++
result.FreedBytes += size
result.RemovedPaths = append(result.RemovedPaths, entry.Name())
}
}
return result, nil
}
// CleanupBySize removes oldest repos until total size is under maxMB.
func CleanupBySize(reposDir string, maxMB int) (*CacheCleanupResult, error) {
result := &CacheCleanupResult{}
maxBytes := int64(maxMB) * 1024 * 1024
entries, err := os.ReadDir(reposDir)
if err != nil {
if os.IsNotExist(err) {
return result, nil
}
return nil, err
}
// Collect repo info with mod time
var repos []repoEntry
var totalSize int64
for _, entry := range entries {
if !entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
repoPath := filepath.Join(reposDir, entry.Name())
size := dirSize(repoPath)
repos = append(repos, repoEntry{
name: entry.Name(),
path: repoPath,
modTime: info.ModTime(),
size: size,
})
totalSize += size
}
if totalSize <= maxBytes {
return result, nil
}
// Sort oldest first
sortReposByModTime(repos)
for _, repo := range repos {
if totalSize <= maxBytes {
break
}
if err := os.RemoveAll(repo.path); err != nil {
continue
}
totalSize -= repo.size
result.RemovedCount++
result.FreedBytes += repo.size
result.RemovedPaths = append(result.RemovedPaths, repo.name)
}
return result, nil
}
// GetCacheStats returns current cache statistics.
func GetCacheStats(reposDir string) (count int, totalBytes int64) {
entries, err := os.ReadDir(reposDir)
if err != nil {
return 0, 0
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
count++
totalBytes += dirSize(filepath.Join(reposDir, entry.Name()))
}
return
}
func sortReposByModTime(repos []repoEntry) {
for i := 0; i < len(repos); i++ {
for j := i + 1; j < len(repos); j++ {
if repos[j].modTime.Before(repos[i].modTime) {
repos[i], repos[j] = repos[j], repos[i]
}
}
}
}
+610
View File
@@ -0,0 +1,610 @@
package services
import (
"bufio"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/go-git/go-git/v5/storage/memory"
)
// RefInfo represents a branch or tag.
type RefInfo struct {
Name string `json:"name"`
Hash string `json:"hash"`
IsHead bool `json:"is_head"`
IsTag bool `json:"is_tag"`
}
// CommitInfo is a commit in the graph.
type CommitInfo struct {
Hash string `json:"hash"`
ShortHash string `json:"short_hash"`
Message string `json:"message"`
Author string `json:"author"`
Email string `json:"email"`
Timestamp string `json:"timestamp"`
ParentIDs []string `json:"parent_ids"`
}
// GraphData is the response for GET /api/repos/:id/graph.
type GraphData struct {
Commits []CommitInfo `json:"commits"`
Refs []RefInfo `json:"refs"`
Edges []Edge `json:"edges"`
}
// Edge connects a commit to a parent.
type Edge struct {
Source string `json:"source"`
Target string `json:"target"`
}
// CloneOptions holds clone configuration.
type CloneOptions struct {
URL string
Dir string
Username string
Password string
Depth int
}
// CloneResult is returned after a successful clone.
type CloneResult struct {
RepoPath string
Branches []string
Tags []string
CommitNum int
SizeBytes int64
}
// Clone clones a repository with optional progress callback.
// progressFn receives SSE-formatted messages; nil means no progress reporting.
func Clone(opts CloneOptions, progressFn func(event string, data interface{})) (*CloneResult, error) {
if err := os.MkdirAll(opts.Dir, 0o755); err != nil {
return nil, fmt.Errorf("create dir: %w", err)
}
cloneOpts := &git.CloneOptions{
URL: opts.URL,
}
if opts.Depth > 0 {
cloneOpts.Depth = opts.Depth
}
if opts.Username != "" {
cloneOpts.Auth = &http.BasicAuth{
Username: opts.Username,
Password: opts.Password,
}
}
if progressFn != nil {
progressFn("progress", map[string]interface{}{
"step": "开始克隆",
"current": 0,
"total": 0,
})
}
repo, err := git.PlainClone(opts.Dir, false, cloneOpts)
if err != nil {
if progressFn != nil {
progressFn("error", map[string]interface{}{
"message": err.Error(),
})
}
return nil, fmt.Errorf("git clone: %w", err)
}
if progressFn != nil {
progressFn("progress", map[string]interface{}{
"step": "克隆完成",
"current": 1,
"total": 1,
})
}
result := &CloneResult{RepoPath: opts.Dir}
// Count commits and collect branches/tags
iter, err := repo.CommitObjects()
if err == nil {
_ = iter.ForEach(func(c *object.Commit) error {
result.CommitNum++
return nil
})
}
branches, _ := repo.Branches()
_ = branches.ForEach(func(ref *plumbing.Reference) error {
result.Branches = append(result.Branches, ref.Name().Short())
return nil
})
tags, _ := repo.Tags()
_ = tags.ForEach(func(ref *plumbing.Reference) error {
result.Tags = append(result.Tags, ref.Name().Short())
return nil
})
result.SizeBytes = dirSize(opts.Dir)
if progressFn != nil {
progressFn("complete", map[string]interface{}{
"repo_id": filepath.Base(opts.Dir),
"size_bytes": result.SizeBytes,
})
}
return result, nil
}
// OpenRepo opens a bare or non-bare repo at the given path.
func OpenRepo(path string) (*git.Repository, error) {
repo, err := git.PlainOpen(path)
if err != nil {
return nil, fmt.Errorf("open repo: %w", err)
}
return repo, nil
}
// GetRefs returns all branches and tags.
func GetRefs(repo *git.Repository) ([]RefInfo, error) {
var refs []RefInfo
head, _ := repo.Head()
branches, err := repo.Branches()
if err != nil {
return nil, err
}
_ = branches.ForEach(func(ref *plumbing.Reference) error {
isHead := head != nil && ref.Hash() == head.Hash()
refs = append(refs, RefInfo{
Name: ref.Name().Short(),
Hash: ref.Hash().String(),
IsHead: isHead,
IsTag: false,
})
return nil
})
tags, err := repo.Tags()
if err != nil {
return nil, err
}
_ = tags.ForEach(func(ref *plumbing.Reference) error {
// For annotated tags, dereference to the commit
hash := ref.Hash()
tagObj, err := repo.TagObject(hash)
if err == nil {
commit, err := tagObj.Commit()
if err == nil {
hash = commit.Hash
}
}
refs = append(refs, RefInfo{
Name: ref.Name().Short(),
Hash: hash.String(),
IsHead: false,
IsTag: true,
})
return nil
})
return refs, nil
}
// GetGraph returns D3.js-compatible graph data with commits, refs, and edges.
func GetGraph(repo *git.Repository, maxCommits int) (*GraphData, error) {
graph := &GraphData{}
// Collect refs first
refs, err := GetRefs(repo)
if err != nil {
return nil, err
}
graph.Refs = refs
// Collect ref hashes to mark commit targets
refHashes := make(map[string]bool)
for _, r := range refs {
refHashes[r.Hash] = true
}
// Walk commits from HEAD
head, err := repo.Head()
if err != nil {
return graph, nil // empty repo
}
commitObj, err := repo.CommitObject(head.Hash())
if err != nil {
return graph, nil
}
seen := make(map[string]bool)
queue := []*object.Commit{commitObj}
for len(queue) > 0 && len(graph.Commits) < maxCommits {
c := queue[0]
queue = queue[1:]
if seen[c.Hash.String()] {
continue
}
seen[c.Hash.String()] = true
ci := CommitInfo{
Hash: c.Hash.String(),
ShortHash: c.Hash.String()[:7],
Message: strings.Split(c.Message, "\n")[0],
Author: c.Author.Name,
Email: c.Author.Email,
Timestamp: c.Author.When.Format(time.RFC3339),
}
for _, parent := range c.ParentHashes {
ci.ParentIDs = append(ci.ParentIDs, parent.String())
graph.Edges = append(graph.Edges, Edge{
Source: c.Hash.String(),
Target: parent.String(),
})
if !seen[parent.String()] {
parentCommit, err := repo.CommitObject(parent)
if err == nil {
queue = append(queue, parentCommit)
}
}
}
graph.Commits = append(graph.Commits, ci)
}
return graph, nil
}
// GetDiff returns the unified diff between two refs for all changed files.
func GetDiff(repo *git.Repository, baseRef, headRef string) (string, error) {
baseHash, err := repo.ResolveRevision(plumbing.Revision(baseRef))
if err != nil {
return "", fmt.Errorf("resolve base ref %q: %w", baseRef, err)
}
headHash, err := repo.ResolveRevision(plumbing.Revision(headRef))
if err != nil {
return "", fmt.Errorf("resolve head ref %q: %w", headRef, err)
}
baseCommit, err := repo.CommitObject(*baseHash)
if err != nil {
return "", fmt.Errorf("base commit: %w", err)
}
headCommit, err := repo.CommitObject(*headHash)
if err != nil {
return "", fmt.Errorf("head commit: %w", err)
}
baseTree, err := baseCommit.Tree()
if err != nil {
return "", err
}
headTree, err := headCommit.Tree()
if err != nil {
return "", err
}
changes, err := object.DiffTree(baseTree, headTree)
if err != nil {
return "", fmt.Errorf("diff tree: %w", err)
}
var diffParts []string
for _, change := range changes {
patch, err := change.Patch()
if err != nil {
continue
}
diffParts = append(diffParts, patch.String())
}
return strings.Join(diffParts, "\n"), nil
}
// GetDiffFiles returns per-file diffs between two refs.
type FileDiff struct {
Filename string `json:"filename"`
Patch string `json:"patch"`
}
func GetDiffFiles(repo *git.Repository, baseRef, headRef string) ([]FileDiff, error) {
baseHash, err := repo.ResolveRevision(plumbing.Revision(baseRef))
if err != nil {
return nil, fmt.Errorf("resolve base ref %q: %w", baseRef, err)
}
headHash, err := repo.ResolveRevision(plumbing.Revision(headRef))
if err != nil {
return nil, fmt.Errorf("resolve head ref %q: %w", headRef, err)
}
baseCommit, err := repo.CommitObject(*baseHash)
if err != nil {
return nil, err
}
headCommit, err := repo.CommitObject(*headHash)
if err != nil {
return nil, err
}
baseTree, err := baseCommit.Tree()
if err != nil {
return nil, err
}
headTree, err := headCommit.Tree()
if err != nil {
return nil, err
}
changes, err := object.DiffTree(baseTree, headTree)
if err != nil {
return nil, err
}
var files []FileDiff
for _, change := range changes {
patch, err := change.Patch()
if err != nil {
continue
}
name := change.To.Name
if name == "" {
name = change.From.Name
}
files = append(files, FileDiff{
Filename: name,
Patch: patch.String(),
})
}
return files, nil
}
// GetCommitLog returns recent commits on a given ref.
func GetCommitLog(repo *git.Repository, refName string, maxCommits int) ([]CommitInfo, error) {
hash, err := repo.ResolveRevision(plumbing.Revision(refName))
if err != nil {
return nil, fmt.Errorf("resolve ref %q: %w", refName, err)
}
commit, err := repo.CommitObject(*hash)
if err != nil {
return nil, err
}
var commits []CommitInfo
seen := make(map[string]bool)
queue := []*object.Commit{commit}
for len(queue) > 0 && len(commits) < maxCommits {
c := queue[0]
queue = queue[1:]
if seen[c.Hash.String()] {
continue
}
seen[c.Hash.String()] = true
commits = append(commits, CommitInfo{
Hash: c.Hash.String(),
ShortHash: c.Hash.String()[:7],
Message: strings.Split(c.Message, "\n")[0],
Author: c.Author.Name,
Email: c.Author.Email,
Timestamp: c.Author.When.Format(time.RFC3339),
ParentIDs: func() []string {
var ids []string
for _, p := range c.ParentHashes {
ids = append(ids, p.String())
}
return ids
}(),
})
for _, parent := range c.ParentHashes {
if !seen[parent.String()] {
p, err := repo.CommitObject(parent)
if err == nil {
queue = append(queue, p)
}
}
}
}
return commits, nil
}
// GetBranchCommits returns commit history for a specific branch.
func GetBranchCommits(repoPath, branchName string, maxCommits int) ([]CommitInfo, error) {
repo, err := OpenRepo(repoPath)
if err != nil {
return nil, err
}
return GetCommitLog(repo, branchName, maxCommits)
}
// FetchRemote fetches latest changes for a repo.
func FetchRemote(repoPath string) error {
repo, err := OpenRepo(repoPath)
if err != nil {
return err
}
remote, err := repo.Remote("origin")
if err != nil {
return err
}
return remote.Fetch(&git.FetchOptions{
Force: true,
})
}
// CloneSSEEvent formats an SSE event for the clone progress stream.
func CloneSSEEvent(event string, data interface{}) string {
return fmt.Sprintf("event: %s\ndata: %s\n\n", event, toJSON(data))
}
// toJSON is a simple JSON encoder for SSE data.
func toJSON(v interface{}) string {
switch d := v.(type) {
case map[string]interface{}:
var parts []string
for k, val := range d {
switch vv := val.(type) {
case string:
parts = append(parts, fmt.Sprintf("%q:%q", k, vv))
case int:
parts = append(parts, fmt.Sprintf("%q:%d", k, vv))
case int64:
parts = append(parts, fmt.Sprintf("%q:%d", k, vv))
default:
parts = append(parts, fmt.Sprintf("%q:%q", k, fmt.Sprintf("%v", vv)))
}
}
return "{" + strings.Join(parts, ",") + "}"
default:
return fmt.Sprintf("%q", fmt.Sprintf("%v", v))
}
}
// dirSize returns the total size of files in a directory.
func dirSize(path string) int64 {
var size int64
_ = filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if !info.IsDir() {
size += info.Size()
}
return nil
})
return size
}
// EstimateSize estimates repo size without cloning (for display).
func EstimateSize(url string) int64 {
// We can't know without cloning, return 0
return 0
}
// GetCloneProgressReader wraps a git clone with progress reporting.
type CloneProgressReader struct {
scanner *bufio.Scanner
progressFn func(event string, data interface{})
}
func NewCloneProgressReader(progressFn func(event string, data interface{})) *CloneProgressReader {
return &CloneProgressReader{
progressFn: progressFn,
}
}
func (r *CloneProgressReader) Read(p []byte) (n int, err error) {
// This is a simplified version - real implementation would parse git protocol
return 0, nil
}
// CompareCommits returns the diff between two commits.
func CompareCommits(repoPath, base, head string) (string, error) {
repo, err := OpenRepo(repoPath)
if err != nil {
return "", err
}
return GetDiff(repo, base, head)
}
// CompareCommitsFiles returns per-file diffs between two commits.
func CompareCommitsFiles(repoPath, base, head string) ([]FileDiff, error) {
repo, err := OpenRepo(repoPath)
if err != nil {
return nil, err
}
return GetDiffFiles(repo, base, head)
}
// GetRepoBranches returns branch names for a repository.
func GetRepoBranches(repoPath string) ([]string, error) {
repo, err := OpenRepo(repoPath)
if err != nil {
return nil, err
}
var branches []string
iter, err := repo.Branches()
if err != nil {
return nil, err
}
_ = iter.ForEach(func(ref *plumbing.Reference) error {
branches = append(branches, ref.Name().Short())
return nil
})
sort.Strings(branches)
return branches, nil
}
// GetRepoTags returns tag names for a repository.
func GetRepoTags(repoPath string) ([]string, error) {
repo, err := OpenRepo(repoPath)
if err != nil {
return nil, err
}
var tags []string
iter, err := repo.Tags()
if err != nil {
return nil, err
}
_ = iter.ForEach(func(ref *plumbing.Reference) error {
tags = append(tags, ref.Name().Short())
return nil
})
sort.Strings(tags)
return tags, nil
}
// CloneWithProgress clones with a pipe-based progress reporter.
func CloneWithProgress(opts CloneOptions, progressFn func(event string, data interface{})) (*CloneResult, error) {
return Clone(opts, progressFn)
}
// GetDefaultBranch returns the default branch name (main or master).
func GetDefaultBranch(repoPath string) (string, error) {
repo, err := OpenRepo(repoPath)
if err != nil {
return "", err
}
head, err := repo.Head()
if err != nil {
return "main", nil
}
return head.Name().Short(), nil
}
// MemoryClone clones to memory for size estimation (lightweight).
func MemoryClone(url string) (*git.Repository, error) {
repo, err := git.Clone(memory.NewStorage(), nil, &git.CloneOptions{
URL: url,
Depth: 1,
})
if err != nil {
return nil, err
}
return repo, nil
}
+135
View File
@@ -0,0 +1,135 @@
// Diff Viewer using diff2html
const DiffViewer = {
init(containerId) {
this.container = document.getElementById(containerId);
if (!this.container) {
console.error('Diff container not found:', containerId);
return;
}
},
// Render unified diff string
renderDiff(diffString, options = {}) {
if (!this.container) return;
const config = {
drawFileList: options.drawFileList !== false,
fileListToggle: options.fileListToggle !== false,
fileListStartVisible: options.fileListStartVisible !== false,
fileContentToggle: options.fileContentToggle !== false,
matching: options.matching || 'lines',
outputFormat: options.outputFormat || 'side-by-side',
synchronisedScroll: true,
highlight: true,
renderNothingWhenEmpty: false,
};
// Use diff2html to render
const diffHtml = Diff2Html.html(diffString, config);
this.container.innerHTML = diffHtml;
// Add syntax highlighting
this.container.querySelectorAll('pre code').forEach(block => {
if (window.hljs) {
hljs.highlightElement(block);
}
});
},
// Render per-file diffs
renderFiles(files, options = {}) {
if (!this.container) return;
if (!files || files.length === 0) {
this.container.innerHTML = '<div class="text-gray-500 p-4">No changes</div>';
return;
}
// Combine all patches
const combinedDiff = files.map(f => f.patch).join('\n');
this.renderDiff(combinedDiff, options);
},
// Load and render diff from API
async loadDiff(repoId, base, head, options = {}) {
if (!this.container) return;
this.container.innerHTML = '<div class="text-center p-8"><div class="spinner inline-block"></div><p class="mt-2 text-gray-500">Loading diff...</p></div>';
try {
const url = `/api/repos/${repoId}/diff?base=${encodeURIComponent(base)}&head=${encodeURIComponent(head)}`;
const resp = await fetch(url);
if (!resp.ok) throw new Error('Failed to load diff');
const data = await resp.json();
if (data.diff) {
this.renderDiff(data.diff, options);
} else if (Array.isArray(data)) {
this.renderFiles(data, options);
}
} catch (err) {
this.container.innerHTML = `<div class="text-red-500 p-4">Error loading diff: ${err.message}</div>`;
}
},
// Load per-file diffs
async loadFiles(repoId, base, head) {
if (!this.container) return;
this.container.innerHTML = '<div class="text-center p-8"><div class="spinner inline-block"></div><p class="mt-2 text-gray-500">Loading files...</p></div>';
try {
const url = `/api/repos/${repoId}/diff?base=${encodeURIComponent(base)}&head=${encodeURIComponent(head)}&per_file=true`;
const resp = await fetch(url);
if (!resp.ok) throw new Error('Failed to load diff');
const files = await resp.json();
this.renderFiles(files);
} catch (err) {
this.container.innerHTML = `<div class="text-red-500 p-4">Error: ${err.message}</div>`;
}
},
// Switch view mode (side-by-side or line-by-line)
setViewMode(mode) {
if (!this.container) return;
const config = {
drawFileList: true,
outputFormat: mode === 'unified' ? 'line-by-line' : 'side-by-side',
};
// Re-render with existing diff content
const existingDiff = this.container.querySelector('.d2h-diff-table');
if (existingDiff) {
// Get the raw diff from the container's data
const rawDiff = this.container.dataset.rawDiff;
if (rawDiff) {
this.renderDiff(rawDiff, config);
}
}
},
// Toggle file list visibility
toggleFileList() {
const fileList = this.container.querySelector('.d2h-file-list');
if (fileList) {
fileList.style.display = fileList.style.display === 'none' ? 'block' : 'none';
}
},
// Expand/collapse all files
toggleAllFiles(expand) {
const fileContents = this.container.querySelectorAll('.d2h-file-wrapper');
fileContents.forEach(file => {
const content = file.querySelector('.d2h-file-diff');
if (content) {
content.style.display = expand ? 'block' : 'none';
}
});
}
};
// Export for use
window.DiffViewer = DiffViewer;
+239
View File
@@ -0,0 +1,239 @@
// Git Graph visualization using D3.js
const GitGraph = {
init(containerId, repoId) {
this.container = document.getElementById(containerId);
this.repoId = repoId;
this.commits = [];
this.refs = [];
this.edges = [];
if (!this.container) {
console.error('Graph container not found:', containerId);
return;
}
this.load();
},
async load() {
try {
const resp = await fetch(`/api/repos/${this.repoId}/graph`);
if (!resp.ok) throw new Error('Failed to load graph');
const data = await resp.json();
this.commits = data.commits || [];
this.refs = data.refs || [];
this.edges = data.edges || [];
this.render();
} catch (err) {
this.container.innerHTML = `<div class="text-red-500 p-4">Error loading graph: ${err.message}</div>`;
}
},
render() {
if (this.commits.length === 0) {
this.container.innerHTML = '<div class="text-gray-500 p-4">No commits found</div>';
return;
}
// Clear container
this.container.innerHTML = '';
const width = this.container.clientWidth || 800;
const nodeHeight = 40;
const height = Math.max(400, this.commits.length * nodeHeight + 60);
const margin = { top: 20, right: 120, bottom: 20, left: 60 };
const svg = d3.select(this.container)
.append('svg')
.attr('width', width)
.attr('height', height);
const g = svg.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
// Build node positions
const nodeMap = {};
const laneWidth = 24;
// Assign lanes to commits (simple algorithm: each branch gets its own lane)
const lanes = this.assignLanes();
this.commits.forEach((commit, i) => {
nodeMap[commit.hash] = {
x: (lanes[commit.hash] || 0) * laneWidth + 20,
y: i * nodeHeight + 20,
commit
};
});
// Draw edges
g.selectAll('.edge')
.data(this.edges.filter(e => nodeMap[e.source] && nodeMap[e.target]))
.enter()
.append('path')
.attr('class', 'edge')
.attr('d', d => {
const s = nodeMap[d.source];
const t = nodeMap[d.target];
if (!s || !t) return '';
const midY = (s.y + t.y) / 2;
return `M ${s.x} ${s.y} C ${s.x} ${midY}, ${t.x} ${midY}, ${t.x} ${t.y}`;
})
.attr('fill', 'none')
.attr('stroke', '#94a3b8')
.attr('stroke-width', 2);
// Draw commit nodes
const nodes = g.selectAll('.node')
.data(this.commits)
.enter()
.append('g')
.attr('class', 'node')
.attr('transform', (d, i) => `translate(${nodeMap[d.hash]?.x || 20},${nodeMap[d.hash]?.y || i * nodeHeight + 20})`);
nodes.append('circle')
.attr('r', 6)
.attr('fill', '#3b82f6')
.attr('stroke', '#1e40af')
.attr('stroke-width', 2);
// Add commit message
nodes.append('text')
.attr('x', 16)
.attr('y', 4)
.text(d => d.message.substring(0, 60) + (d.message.length > 60 ? '...' : ''))
.attr('font-size', '12px')
.attr('fill', '#374151');
// Add short hash
nodes.append('text')
.attr('x', 16)
.attr('y', -8)
.text(d => d.short_hash)
.attr('font-size', '10px')
.attr('fill', '#6b7280')
.attr('font-family', 'monospace');
// Draw refs
const refGroups = g.selectAll('.ref')
.data(this.refs)
.enter()
.append('g')
.attr('class', 'ref');
refGroups.each((ref, i, elements) => {
const commitNode = nodeMap[ref.hash];
if (!commitNode) return;
const group = d3.select(elements[i]);
const isTag = ref.is_tag;
const color = isTag ? '#10b981' : (ref.is_head ? '#8b5cf6' : '#3b82f6');
const bbox = { x: commitNode.x + 30, y: commitNode.y - 10 };
group.append('rect')
.attr('x', bbox.x)
.attr('y', bbox.y)
.attr('rx', 4)
.attr('ry', 4)
.attr('fill', color)
.attr('opacity', 0.1)
.attr('stroke', color)
.attr('stroke-width', 1);
const text = group.append('text')
.attr('x', bbox.x + 6)
.attr('y', bbox.y + 14)
.text(ref.name)
.attr('font-size', '11px')
.attr('fill', color)
.attr('font-weight', '500');
// Size rect to text
const textBBox = text.node().getBBox();
group.select('rect')
.attr('width', textBBox.width + 12)
.attr('height', textBBox.height + 6);
});
// Add hover effect
nodes.on('mouseover', function(event, d) {
d3.select(this).select('circle')
.attr('r', 8)
.attr('fill', '#2563eb');
}).on('mouseout', function() {
d3.select(this).select('circle')
.attr('r', 6)
.attr('fill', '#3b82f6');
});
},
assignLanes() {
// Simple lane assignment: find branches and assign lanes
const lanes = {};
const branchHeads = {};
// Find branch head commits
this.refs.forEach(ref => {
if (!ref.is_tag) {
branchHeads[ref.hash] = ref.name;
}
});
// BFS from branch heads to assign lanes
let nextLane = 0;
const visited = new Set();
// First assign HEAD branch
const headRef = this.refs.find(r => r.is_head);
if (headRef) {
const queue = [headRef.hash];
while (queue.length > 0) {
const hash = queue.shift();
if (visited.has(hash)) continue;
visited.add(hash);
lanes[hash] = 0;
// Find children (commits that have this as parent)
const children = this.edges.filter(e => e.target === hash).map(e => e.source);
children.forEach(child => {
if (!visited.has(child)) queue.push(child);
});
}
nextLane = 1;
}
// Assign lanes to other branches
this.refs.filter(r => !r.is_head && !r.is_tag).forEach(ref => {
if (visited.has(ref.hash)) return;
const queue = [ref.hash];
while (queue.length > 0) {
const hash = queue.shift();
if (visited.has(hash)) continue;
visited.add(hash);
lanes[hash] = nextLane;
const children = this.edges.filter(e => e.target === hash).map(e => e.source);
children.forEach(child => {
if (!visited.has(child)) queue.push(child);
});
}
nextLane++;
});
// Assign any remaining commits
this.commits.forEach(commit => {
if (!lanes[commit.hash]) {
lanes[commit.hash] = 0;
}
});
return lanes;
}
};
// Export for use
window.GitGraph = GitGraph;
+166 -68
View File
@@ -1,102 +1,200 @@
<!DOCTYPE html>
<html lang="zh-CN" class="h-full">
<html lang="zh-CN">
<head>
{{template "head" .}}
<body class="h-full bg-gray-50 text-gray-900">
<div class="min-h-full flex flex-col">
</head>
<body class="bg-gray-50 min-h-screen">
{{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">
<main class="max-w-4xl mx-auto px-4 py-8">
<h1 class="text-2xl font-bold text-gray-900 mb-6">生成 PR 描述</h1>
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
<div class="grid grid-cols-2 gap-4 mb-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">
<label class="block text-sm font-medium text-gray-700 mb-1">Base 分支</label>
<select id="base-ref" class="w-full border rounded-md px-3 py-2" required>
<option value="">加载中...</option>
</select>
</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">
<label class="block text-sm font-medium text-gray-700 mb-1">Head 分支</label>
<select id="head-ref" class="w-full border rounded-md px-3 py-2" required>
<option value="">加载中...</option>
</select>
</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">
<button onclick="generate()" id="btn-generate" class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700">
生成 PR 描述
</button>
</div>
<!-- Output -->
<div id="pr-output" class="hidden bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div id="output" class="bg-white rounded-lg shadow-md p-6 hidden">
<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>
<button onclick="copyMarkdown()" class="px-3 py-1 bg-gray-100 text-gray-700 rounded hover:bg-gray-200">
复制 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 id="output-content">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-500">标题</label>
<p id="pr-title" class="text-lg font-semibold text-gray-900"></p>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-500">类型</label>
<span id="pr-type" class="inline-block px-2 py-1 bg-blue-100 text-blue-800 rounded text-sm"></span>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-500">摘要</label>
<p id="pr-summary" class="text-gray-700"></p>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-500">详细说明</label>
<ul id="pr-details" class="list-disc list-inside text-gray-700 space-y-1"></ul>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-500">影响</label>
<p id="pr-impact" class="text-gray-700"></p>
</div>
<div class="mt-6 p-4 bg-gray-50 rounded-md">
<label class="block text-sm font-medium text-gray-500 mb-2">Markdown 预览</label>
<pre id="pr-markdown" class="whitespace-pre-wrap text-sm text-gray-800 font-mono"></pre>
</div>
</div>
<!-- Loading indicator -->
<div id="loading" class="text-center py-8 hidden">
<div class="spinner inline-block"></div>
<p class="mt-2 text-gray-500">正在生成 PR 描述...</p>
</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');
const repoId = {{.ID}};
// Load refs on page load
async function loadRefs() {
try {
const resp = await fetch('/api/repos/{{.ID}}/generate', {
const resp = await fetch(`/api/repos/${repoId}/refs`);
if (!resp.ok) throw new Error('Failed to load refs');
const refs = await resp.json();
const baseSelect = document.getElementById('base-ref');
const headSelect = document.getElementById('head-ref');
baseSelect.innerHTML = '<option value="">选择分支</option>';
headSelect.innerHTML = '<option value="">选择分支</option>';
const branches = refs.filter(r => !r.is_tag);
branches.forEach(ref => {
const opt1 = document.createElement('option');
opt1.value = ref.name;
opt1.textContent = ref.name + (ref.is_head ? ' (HEAD)' : '');
baseSelect.appendChild(opt1);
const opt2 = document.createElement('option');
opt2.value = ref.name;
opt2.textContent = ref.name + (ref.is_head ? ' (HEAD)' : '');
headSelect.appendChild(opt2);
});
// Auto-select main/master as base, HEAD as head
const mainBranch = branches.find(b => b.name === 'main' || b.name === 'master');
const headBranch = branches.find(r => r.is_head);
if (mainBranch) baseSelect.value = mainBranch.name;
if (headBranch) headSelect.value = headBranch.name;
} catch (err) {
console.error('Load refs error:', err);
}
}
async function generate() {
const baseRef = document.getElementById('base-ref').value;
const headRef = document.getElementById('head-ref').value;
if (!baseRef || !headRef) {
alert('请选择 Base 和 Head 分支');
return;
}
const btn = document.getElementById('btn-generate');
const output = document.getElementById('output');
const loading = document.getElementById('loading');
btn.disabled = true;
btn.textContent = '生成中...';
output.classList.remove('hidden');
document.getElementById('output-content').classList.add('hidden');
loading.classList.remove('hidden');
try {
const resp = await fetch(`/api/repos/${repoId}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({base, head})
body: JSON.stringify({ base_ref: baseRef, head_ref: headRef })
});
if (!resp.ok) throw new Error('生成失败');
const data = await resp.json();
document.getElementById('pr-title').textContent = data.title || '';
document.getElementById('pr-type').textContent = data.type || '';
document.getElementById('pr-summary').textContent = data.summary || '';
document.getElementById('pr-impact').textContent = data.impact || '';
const detailsList = document.getElementById('pr-details');
detailsList.innerHTML = '';
if (data.details && Array.isArray(data.details)) {
data.details.forEach(item => {
const li = document.createElement('li');
li.textContent = item;
detailsList.appendChild(li);
});
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 {}
}
}
}
document.getElementById('pr-markdown').textContent = data.markdown || '';
loading.classList.add('hidden');
document.getElementById('output-content').classList.remove('hidden');
} catch (err) {
document.getElementById('pr-summary').textContent = '错误: ' + err.message;
alert('生成失败: ' + err.message);
output.classList.add('hidden');
} finally {
btn.disabled = false; btn.textContent = '生成 PR 描述';
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);
const markdown = document.getElementById('pr-markdown').textContent;
navigator.clipboard.writeText(markdown).then(() => {
alert('已复制到剪贴板');
}).catch(() => {
// Fallback
const textarea = document.createElement('textarea');
textarea.value = markdown;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
alert('已复制到剪贴板');
});
}
loadRefs();
</script>
</body>
</html>
+196 -28
View File
@@ -1,51 +1,219 @@
<!DOCTYPE html>
<html lang="zh-CN" class="h-full">
<html lang="zh-CN">
<head>
{{template "head" .}}
<body class="h-full bg-gray-50 text-gray-900">
<div class="min-h-full flex flex-col">
<link rel="stylesheet" href="/static/lib/diff2html.min.css">
<script src="/static/lib/d3.min.js"></script>
<script src="/static/lib/diff2html.min.js"></script>
<script src="/static/lib/highlight.min.js"></script>
<script src="/static/js/graph.js"></script>
<script src="/static/js/diff-viewer.js"></script>
<style>
.graph-container {
background: #f8fafc;
border-radius: 8px;
min-height: 300px;
overflow: auto;
}
.graph-container svg {
display: block;
}
.ref-tag {
cursor: pointer;
}
.ref-tag:hover {
opacity: 0.8;
}
</style>
</head>
<body class="bg-gray-50 min-h-screen">
{{template "nav" .}}
<main class="flex-1">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<main class="max-w-7xl mx-auto px-4 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">
<h1 class="text-2xl font-bold text-gray-900">
<span id="repo-url" class="text-gray-500 text-lg font-normal"></span>
</h1>
<div class="flex gap-2">
<a id="btn-generate" href="#" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-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">
<a id="btn-review" href="#" class="px-4 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-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>
<!-- Tabs -->
<div class="bg-white rounded-lg shadow-md">
<div class="border-b border-gray-200">
<nav class="flex -mb-px">
<button onclick="showTab('graph')" id="tab-graph" class="px-6 py-3 text-sm font-medium border-b-2 border-blue-500 text-blue-600">
Git Graph
</button>
<button onclick="showTab('diff')" id="tab-diff" class="px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300">
Diff 视图
</button>
</nav>
</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>
<!-- Graph Tab -->
<div id="panel-graph" class="p-4">
<div class="flex items-center gap-4 mb-4">
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600">Base:</label>
<select id="graph-base" class="border rounded px-2 py-1 text-sm">
<option value="">选择分支/标签</option>
</select>
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600">Head:</label>
<select id="graph-head" class="border rounded px-2 py-1 text-sm">
<option value="">选择分支/标签</option>
</select>
</div>
</div>
<div id="git-graph" class="graph-container p-4"></div>
</div>
<!-- Diff Tab -->
<div id="panel-diff" class="p-4 hidden">
<div class="flex items-center gap-4 mb-4">
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600">Base:</label>
<select id="diff-base" class="border rounded px-2 py-1 text-sm">
<option value="">选择分支/标签</option>
</select>
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600">Head:</label>
<select id="diff-head" class="border rounded px-2 py-1 text-sm">
<option value="">选择分支/标签</option>
</select>
</div>
<button onclick="loadDiff()" class="px-3 py-1 bg-blue-600 text-white rounded text-sm hover:bg-blue-700">
查看 Diff
</button>
<div class="flex gap-1 ml-auto">
<button onclick="setDiffView('side-by-side')" class="px-2 py-1 text-xs bg-gray-100 rounded hover:bg-gray-200">并排</button>
<button onclick="setDiffView('unified')" class="px-2 py-1 text-xs bg-gray-100 rounded hover:bg-gray-200">统一</button>
</div>
</div>
<div id="diff-container"></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');
const repoId = {{.ID}};
let currentRefs = [];
// Load repo info and refs
async function init() {
try {
// Load refs
const refsResp = await fetch(`/api/repos/${repoId}/refs`);
if (refsResp.ok) {
currentRefs = await refsResp.json();
populateRefSelects(currentRefs);
}
// Load repo info
const reposResp = await fetch('/api/repos');
if (reposResp.ok) {
const repos = await reposResp.json();
const repo = repos.find(r => r.id === repoId);
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>';
document.getElementById('repo-url').textContent = repo.url;
document.getElementById('btn-generate').href = `/repo/${repoId}/generate`;
document.getElementById('btn-review').href = `/repo/${repoId}/review`;
}
}
// Init graph
GitGraph.init('git-graph', repoId);
DiffViewer.init('diff-container');
} catch (err) {
console.error('Init error:', err);
}
}
function populateRefSelects(refs) {
const selects = ['graph-base', 'graph-head', 'diff-base', 'diff-head'];
selects.forEach(selectId => {
const select = document.getElementById(selectId);
select.innerHTML = '<option value="">选择分支/标签</option>';
// Branches first
const branches = refs.filter(r => !r.is_tag);
const tags = refs.filter(r => r.is_tag);
if (branches.length > 0) {
const group = document.createElement('optgroup');
group.label = '分支';
branches.forEach(ref => {
const opt = document.createElement('option');
opt.value = ref.name;
opt.textContent = ref.name + (ref.is_head ? ' (HEAD)' : '');
group.appendChild(opt);
});
select.appendChild(group);
}
if (tags.length > 0) {
const group = document.createElement('optgroup');
group.label = '标签';
tags.forEach(ref => {
const opt = document.createElement('option');
opt.value = ref.name;
opt.textContent = ref.name;
group.appendChild(opt);
});
select.appendChild(group);
}
});
// Auto-select main/master as base, HEAD as head
const mainBranch = branches.find(b => b.name === 'main' || b.name === 'master');
const headBranch = refs.find(r => r.is_head);
selects.forEach(selectId => {
const select = document.getElementById(selectId);
if (selectId.includes('base') && mainBranch) {
select.value = mainBranch.name;
} else if (selectId.includes('head') && headBranch) {
select.value = headBranch.name;
}
});
}
function showTab(tab) {
document.getElementById('panel-graph').classList.toggle('hidden', tab !== 'graph');
document.getElementById('panel-diff').classList.toggle('hidden', tab !== 'diff');
document.getElementById('tab-graph').classList.toggle('border-blue-500', tab === 'graph');
document.getElementById('tab-graph').classList.toggle('text-blue-600', tab === 'graph');
document.getElementById('tab-diff').classList.toggle('border-blue-500', tab === 'diff');
document.getElementById('tab-diff').classList.toggle('text-blue-600', tab === 'diff');
}
function loadDiff() {
const base = document.getElementById('diff-base').value;
const head = document.getElementById('diff-head').value;
if (!base || !head) {
alert('请选择 Base 和 Head 分支');
return;
}
DiffViewer.loadDiff(repoId, base, head);
}
function setDiffView(mode) {
DiffViewer.setViewMode(mode);
}
init();
</script>
</body>
</html>
+172 -81
View File
@@ -1,113 +1,204 @@
<!DOCTYPE html>
<html lang="zh-CN" class="h-full">
<html lang="zh-CN">
<head>
{{template "head" .}}
<body class="h-full bg-gray-50 text-gray-900">
<div class="min-h-full flex flex-col">
<link rel="stylesheet" href="/static/lib/diff2html.min.css">
<script src="/static/lib/diff2html.min.js"></script>
<script src="/static/lib/highlight.min.js"></script>
<script src="/static/js/diff-viewer.js"></script>
</head>
<body class="bg-gray-50 min-h-screen">
{{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">
<main class="max-w-6xl mx-auto px-4 py-8">
<h1 class="text-2xl font-bold text-gray-900 mb-6">AI 代码审查</h1>
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
<div class="grid grid-cols-3 gap-4 mb-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">
<label class="block text-sm font-medium text-gray-700 mb-1">Base 分支</label>
<select id="base-ref" class="w-full border rounded-md px-3 py-2" required>
<option value="">加载中...</option>
</select>
</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">
<label class="block text-sm font-medium text-gray-700 mb-1">Head 分支</label>
<select id="head-ref" class="w-full border rounded-md px-3 py-2" required>
<option value="">加载中...</option>
</select>
</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>
<input type="number" id="top-n" value="20" min="1" max="100" class="w-full border rounded-md px-3 py-2">
</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 onclick="startReview()" id="btn-review" class="w-full bg-purple-600 text-white py-2 px-4 rounded-md hover:bg-purple-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>
<!-- Progress -->
<div id="progress" class="bg-white rounded-lg shadow-md p-6 mb-6 hidden">
<h3 class="font-semibold text-gray-700 mb-2">审查进度</h3>
<div class="w-full bg-gray-200 rounded-full h-2.5 mb-2">
<div id="progress-bar" class="bg-purple-600 h-2.5 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<div id="file-reviews" class="space-y-4"></div>
<p id="progress-text" class="text-sm text-gray-500">准备中...</p>
</div>
<!-- Results -->
<div id="results" class="hidden">
<!-- Summary -->
<div id="summary" class="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">审查总结</h2>
<div id="summary-content" class="text-gray-700"></div>
</div>
<!-- File Reviews -->
<div id="file-reviews" class="space-y-4">
<!-- Dynamically populated -->
</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;
const repoId = {{.ID}};
// Load refs on page load
async function loadRefs() {
try {
const resp = await fetch('/api/repos/{{.ID}}/review', {
const resp = await fetch(`/api/repos/${repoId}/refs`);
if (!resp.ok) throw new Error('Failed to load refs');
const refs = await resp.json();
const baseSelect = document.getElementById('base-ref');
const headSelect = document.getElementById('head-ref');
baseSelect.innerHTML = '<option value="">选择分支</option>';
headSelect.innerHTML = '<option value="">选择分支</option>';
const branches = refs.filter(r => !r.is_tag);
branches.forEach(ref => {
const opt1 = document.createElement('option');
opt1.value = ref.name;
opt1.textContent = ref.name + (ref.is_head ? ' (HEAD)' : '');
baseSelect.appendChild(opt1);
const opt2 = document.createElement('option');
opt2.value = ref.name;
opt2.textContent = ref.name + (ref.is_head ? ' (HEAD)' : '');
headSelect.appendChild(opt2);
});
// Auto-select main/master as base, HEAD as head
const mainBranch = branches.find(b => b.name === 'main' || b.name === 'master');
const headBranch = branches.find(r => r.is_head);
if (mainBranch) baseSelect.value = mainBranch.name;
if (headBranch) headSelect.value = headBranch.name;
} catch (err) {
console.error('Load refs error:', err);
}
}
async function startReview() {
const baseRef = document.getElementById('base-ref').value;
const headRef = document.getElementById('head-ref').value;
const topN = document.getElementById('top-n').value;
if (!baseRef || !headRef) {
alert('请选择 Base 和 Head 分支');
return;
}
const btn = document.getElementById('btn-review');
const progress = document.getElementById('progress');
const results = document.getElementById('results');
btn.disabled = true;
btn.textContent = '审查中...';
progress.classList.remove('hidden');
results.classList.add('hidden');
try {
const resp = await fetch(`/api/repos/${repoId}/review`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({base, head, top_n: topN})
body: JSON.stringify({ base_ref: baseRef, head_ref: headRef, top_n: parseInt(topN) })
});
if (!resp.ok) throw new Error('审查失败');
const data = await resp.json();
// Display summary
document.getElementById('summary-content').innerHTML = data.summary || '无总结';
// Display file reviews
const container = document.getElementById('file-reviews');
container.innerHTML = '';
if (data.files && Array.isArray(data.files)) {
data.files.forEach(file => {
const card = createFileCard(file);
container.appendChild(card);
});
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 {}
}
}
}
results.classList.remove('hidden');
} catch (err) {
document.getElementById('review-summary').textContent = '错误: ' + err.message;
alert('审查失败: ' + err.message);
} finally {
btn.disabled = false; btn.textContent = '开始审查';
btn.disabled = false;
btn.textContent = '开始审查';
progress.classList.add('hidden');
}
}
function createFileCard(file) {
const severityColors = {
critical: 'bg-red-100 text-red-800 border-red-200',
warning: 'bg-yellow-100 text-yellow-800 border-yellow-200',
info: 'bg-green-100 text-green-800 border-green-200'
};
const card = document.createElement('div');
card.className = 'bg-white rounded-lg shadow-md border-l-4 ' + (severityColors[file.max_severity] || severityColors.info);
let suggestionsHtml = '';
if (file.suggestions && Array.isArray(file.suggestions)) {
suggestionsHtml = file.suggestions.map(s => `
<div class="mt-2 p-2 bg-gray-50 rounded">
<div class="flex items-center gap-2 mb-1">
<span class="px-2 py-0.5 rounded text-xs font-medium ${severityColors[s.severity] || severityColors.info}">
${s.severity || 'info'}
</span>
${s.line ? `<span class="text-xs text-gray-500">行 ${s.line}</span>` : ''}
</div>
<p class="text-sm text-gray-700">${s.content || ''}</p>
</div>
`).join('');
}
card.innerHTML = `
<div class="p-4">
<div class="flex items-center justify-between">
<h3 class="font-medium text-gray-900">${file.filename || ''}</h3>
<span class="px-2 py-1 rounded text-xs font-medium ${severityColors[file.max_severity] || severityColors.info}">
${file.max_severity || 'info'}
</span>
</div>
<p class="mt-2 text-sm text-gray-600">${file.summary || ''}</p>
${suggestionsHtml}
</div>
`;
return card;
}
loadRefs();
</script>
</body>
</html>