diff --git a/go.mod b/go.mod index 9c55523..db1cfc1 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 22c440e..82b3e74 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/handlers/repos.go b/handlers/repos.go index b91c496..a67b168 100644 --- a/handlers/repos.go +++ b/handlers/repos.go @@ -2,15 +2,20 @@ 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" ) type ReposHandler struct { - db *sql.DB + db *sql.DB reposDir string } @@ -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 + } + + // 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 — stub for Phase 2 +// 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 + } + + // 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 — stub for Phase 2 +// 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 { diff --git a/main.go b/main.go index 6eb5773..de77d6f 100644 --- a/main.go +++ b/main.go @@ -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) diff --git a/services/cache.go b/services/cache.go new file mode 100644 index 0000000..dd818a4 --- /dev/null +++ b/services/cache.go @@ -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] + } + } + } +} diff --git a/services/git.go b/services/git.go new file mode 100644 index 0000000..c74c28b --- /dev/null +++ b/services/git.go @@ -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 +} diff --git a/static/js/diff-viewer.js b/static/js/diff-viewer.js new file mode 100644 index 0000000..1dca1c4 --- /dev/null +++ b/static/js/diff-viewer.js @@ -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 = '
No changes
'; + 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 = '

Loading diff...

'; + + 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 = `
Error loading diff: ${err.message}
`; + } + }, + + // Load per-file diffs + async loadFiles(repoId, base, head) { + if (!this.container) return; + + this.container.innerHTML = '

Loading files...

'; + + 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 = `
Error: ${err.message}
`; + } + }, + + // 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; diff --git a/static/js/graph.js b/static/js/graph.js new file mode 100644 index 0000000..071e9b2 --- /dev/null +++ b/static/js/graph.js @@ -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 = `
Error loading graph: ${err.message}
`; + } + }, + + render() { + if (this.commits.length === 0) { + this.container.innerHTML = '
No commits found
'; + 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; diff --git a/templates/pages/generate.html b/templates/pages/generate.html index 05d019e..ec2ceef 100644 --- a/templates/pages/generate.html +++ b/templates/pages/generate.html @@ -1,102 +1,200 @@ - -{{template "head" .}} - -
+ + + {{template "head" .}} + + {{template "nav" .}} -
-
-

生成 PR 描述

- -
-

选择范围

-
-
- - -
-
- - -
+ +
+

生成 PR 描述

+ +
+
+
+ +
-
+ +
+ + +
+ {{template "footer" .}} -
- + + 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_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); + }); + } + + document.getElementById('pr-markdown').textContent = data.markdown || ''; + + loading.classList.add('hidden'); + document.getElementById('output-content').classList.remove('hidden'); + } catch (err) { + alert('生成失败: ' + err.message); + output.classList.add('hidden'); + } finally { + btn.disabled = false; + btn.textContent = '生成 PR 描述'; + } + } + + function copyMarkdown() { + 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(); + diff --git a/templates/pages/repo.html b/templates/pages/repo.html index 8c29edc..3dd5d96 100644 --- a/templates/pages/repo.html +++ b/templates/pages/repo.html @@ -1,51 +1,219 @@ - -{{template "head" .}} - -
+ + + {{template "head" .}} + + + + + + + + + {{template "nav" .}} -
-
-
-

仓库详情

- + +
+
+

+ +

+ - -
-
加载中...
+
+ + +
+
+
- -
-

Git Graph

-
-

Git Graph 将在 Phase 3 实现

+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ {{template "footer" .}} -
- + + diff --git a/templates/pages/review.html b/templates/pages/review.html index e306baa..17a142a 100644 --- a/templates/pages/review.html +++ b/templates/pages/review.html @@ -1,113 +1,204 @@ - -{{template "head" .}} - -
+ + + {{template "head" .}} + + + + + + {{template "nav" .}} -
-
-

AI 代码审查

- -
-

审查配置

-
-
- - -
-
- - -
-
- - -

0 = 分析全部文件

-
+ +
+

AI 代码审查

+ +
+
+
+ + +
+
+ + +
+
+ +
-
- - + + + + + +
+ {{template "footer" .}} -
- + + 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_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); + }); + } + + results.classList.remove('hidden'); + } catch (err) { + alert('审查失败: ' + err.message); + } finally { + 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 => ` +
+
+ + ${s.severity || 'info'} + + ${s.line ? `行 ${s.line}` : ''} +
+

${s.content || ''}

+
+ `).join(''); + } + + card.innerHTML = ` +
+
+

${file.filename || ''}

+ + ${file.max_severity || 'info'} + +
+

${file.summary || ''}

+ ${suggestionsHtml} +
+ `; + + return card; + } + + loadRefs(); +