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 = '';
+
+ 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 = '';
+
+ 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 描述
+
+
+
+
+
+
-
+
+
+
+
+
+
+
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();
+