feat: Phase 2 — Git 核心功能,go-git 克隆、SSE 进度、D3.js 图形、diff2html 查看器
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
||||
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
|
||||
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||
github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
|
||||
github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
|
||||
github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00=
|
||||
github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -25,15 +44,26 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
|
||||
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -47,13 +77,23 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
|
||||
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
|
||||
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
@@ -65,25 +105,52 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+218
-11
@@ -2,10 +2,15 @@ package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/HoHD/PR-Helper/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -56,22 +61,18 @@ func (h *ReposHandler) DeleteRepo(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Remove from filesystem
|
||||
os.RemoveAll(localPath)
|
||||
// Remove from database
|
||||
h.db.Exec(`DELETE FROM analyses WHERE repo_id = ?`, id)
|
||||
h.db.Exec(`DELETE FROM repositories WHERE id = ?`, id)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *ReposHandler) CleanupRepos(c *gin.Context) {
|
||||
// Get max age from settings
|
||||
var maxAgeDays string
|
||||
h.db.QueryRow(`SELECT value FROM settings WHERE key = 'cache.max_age_days'`).Scan(&maxAgeDays)
|
||||
if maxAgeDays == "" {
|
||||
maxAgeDays = "7"
|
||||
}
|
||||
// Find expired repos
|
||||
rows, err := h.db.Query(`SELECT id, local_path FROM repositories WHERE last_used < datetime('now', '-' || ? || ' days')`, maxAgeDays)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -92,22 +93,228 @@ func (h *ReposHandler) CleanupRepos(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"cleaned": cleaned})
|
||||
}
|
||||
|
||||
// CloneRepo handles POST /api/repos — stub for Phase 2
|
||||
// CloneRepo handles POST /api/repos with SSE progress events.
|
||||
func (h *ReposHandler) CloneRepo(c *gin.Context) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "clone not implemented yet — coming in Phase 2"})
|
||||
var req struct {
|
||||
URL string `json:"url" binding:"required"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "url is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// GetGraph handles GET /api/repos/:id/graph — stub for Phase 2
|
||||
// Set SSE headers
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "streaming not supported"})
|
||||
return
|
||||
}
|
||||
|
||||
sendEvent := func(event string, data interface{}) {
|
||||
jsonData, _ := json.Marshal(data)
|
||||
fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, jsonData)
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
sendEvent("start", map[string]interface{}{"url": req.URL})
|
||||
|
||||
// Generate unique directory name
|
||||
repoName := filepath.Base(req.URL)
|
||||
if repoName == "" || repoName == "." || repoName == "/" {
|
||||
repoName = fmt.Sprintf("repo_%d", time.Now().UnixNano())
|
||||
}
|
||||
repoDir := filepath.Join(h.reposDir, fmt.Sprintf("%s_%d", repoName, time.Now().UnixNano()))
|
||||
|
||||
result, err := services.Clone(services.CloneOptions{
|
||||
URL: req.URL,
|
||||
Dir: repoDir,
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
}, func(event string, data interface{}) {
|
||||
sendEvent(event, data)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
sendEvent("error", map[string]interface{}{"message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Save to database
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
res, err := h.db.Exec(`INSERT INTO repositories (url, local_path, size_bytes, cloned_at, last_used) VALUES (?, ?, ?, ?, ?)`,
|
||||
req.URL, repoDir, result.SizeBytes, now, now)
|
||||
if err != nil {
|
||||
sendEvent("error", map[string]interface{}{"message": "save to db: " + err.Error()})
|
||||
return
|
||||
}
|
||||
repoID, _ := res.LastInsertId()
|
||||
|
||||
sendEvent("complete", map[string]interface{}{
|
||||
"repo_id": repoID,
|
||||
"size_bytes": result.SizeBytes,
|
||||
})
|
||||
}
|
||||
|
||||
// GetGraph handles GET /api/repos/:id/graph — returns D3.js-compatible data.
|
||||
func (h *ReposHandler) GetGraph(c *gin.Context) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "graph not implemented yet — coming in Phase 2"})
|
||||
id := c.Param("id")
|
||||
var localPath string
|
||||
err := h.db.QueryRow(`SELECT local_path FROM repositories WHERE id = ?`, id).Scan(&localPath)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "repository not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// GetDiff handles GET /api/repos/:id/diff — stub for Phase 2
|
||||
// Update last_used
|
||||
h.db.Exec(`UPDATE repositories SET last_used = datetime('now') WHERE id = ?`, id)
|
||||
|
||||
repo, err := services.OpenRepo(localPath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "open repo: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
maxCommits := 200
|
||||
if mc := c.Query("max_commits"); mc != "" {
|
||||
if n, err := strconv.Atoi(mc); err == nil && n > 0 {
|
||||
maxCommits = n
|
||||
}
|
||||
}
|
||||
|
||||
graph, err := services.GetGraph(repo, maxCommits)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, graph)
|
||||
}
|
||||
|
||||
// GetDiff handles GET /api/repos/:id/diff — returns unified diff or per-file diffs.
|
||||
func (h *ReposHandler) GetDiff(c *gin.Context) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "diff not implemented yet — coming in Phase 2"})
|
||||
id := c.Param("id")
|
||||
base := c.Query("base")
|
||||
head := c.Query("head")
|
||||
|
||||
if base == "" || head == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "base and head query params are required"})
|
||||
return
|
||||
}
|
||||
|
||||
var localPath string
|
||||
err := h.db.QueryRow(`SELECT local_path FROM repositories WHERE id = ?`, id).Scan(&localPath)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "repository not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Update last_used
|
||||
h.db.Exec(`UPDATE repositories SET last_used = datetime('now') WHERE id = ?`, id)
|
||||
|
||||
repo, err := services.OpenRepo(localPath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if per-file mode is requested
|
||||
if c.Query("per_file") == "true" {
|
||||
files, err := services.GetDiffFiles(repo, base, head)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, files)
|
||||
return
|
||||
}
|
||||
|
||||
diff, err := services.GetDiff(repo, base, head)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"diff": diff})
|
||||
}
|
||||
|
||||
// GetRefs handles GET /api/repos/:id/refs — returns branches and tags.
|
||||
func (h *ReposHandler) GetRefs(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var localPath string
|
||||
err := h.db.QueryRow(`SELECT local_path FROM repositories WHERE id = ?`, id).Scan(&localPath)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "repository not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
repo, err := services.OpenRepo(localPath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
refs, err := services.GetRefs(repo)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, refs)
|
||||
}
|
||||
|
||||
// GetCommits handles GET /api/repos/:id/commits — returns commit log for a ref.
|
||||
func (h *ReposHandler) GetCommits(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
refName := c.Query("ref")
|
||||
if refName == "" {
|
||||
refName = "HEAD"
|
||||
}
|
||||
maxCommits := 100
|
||||
if mc := c.Query("limit"); mc != "" {
|
||||
if n, err := strconv.Atoi(mc); err == nil && n > 0 {
|
||||
maxCommits = n
|
||||
}
|
||||
}
|
||||
|
||||
var localPath string
|
||||
err := h.db.QueryRow(`SELECT local_path FROM repositories WHERE id = ?`, id).Scan(&localPath)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "repository not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
commits, err := services.GetBranchCommits(localPath, refName, maxCommits)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, commits)
|
||||
}
|
||||
|
||||
// dirSize returns the total size of a directory in bytes
|
||||
func dirSize(path string) int64 {
|
||||
var size int64
|
||||
filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CacheCleanupResult holds the outcome of a cleanup operation.
|
||||
type CacheCleanupResult struct {
|
||||
RemovedCount int `json:"removed_count"`
|
||||
FreedBytes int64 `json:"freed_bytes"`
|
||||
RemovedPaths []string `json:"removed_paths"`
|
||||
}
|
||||
|
||||
type repoEntry struct {
|
||||
name string
|
||||
path string
|
||||
modTime time.Time
|
||||
size int64
|
||||
}
|
||||
|
||||
// CleanupExpiredRepos removes repos older than maxAge days from reposDir.
|
||||
func CleanupExpiredRepos(reposDir string, maxAgeDays int) (*CacheCleanupResult, error) {
|
||||
result := &CacheCleanupResult{}
|
||||
cutoff := time.Now().AddDate(0, 0, -maxAgeDays)
|
||||
|
||||
entries, err := os.ReadDir(reposDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return result, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read repos dir: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.ModTime().Before(cutoff) {
|
||||
repoPath := filepath.Join(reposDir, entry.Name())
|
||||
size := dirSize(repoPath)
|
||||
if err := os.RemoveAll(repoPath); err != nil {
|
||||
continue
|
||||
}
|
||||
result.RemovedCount++
|
||||
result.FreedBytes += size
|
||||
result.RemovedPaths = append(result.RemovedPaths, entry.Name())
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CleanupBySize removes oldest repos until total size is under maxMB.
|
||||
func CleanupBySize(reposDir string, maxMB int) (*CacheCleanupResult, error) {
|
||||
result := &CacheCleanupResult{}
|
||||
maxBytes := int64(maxMB) * 1024 * 1024
|
||||
|
||||
entries, err := os.ReadDir(reposDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return result, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Collect repo info with mod time
|
||||
var repos []repoEntry
|
||||
var totalSize int64
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
repoPath := filepath.Join(reposDir, entry.Name())
|
||||
size := dirSize(repoPath)
|
||||
repos = append(repos, repoEntry{
|
||||
name: entry.Name(),
|
||||
path: repoPath,
|
||||
modTime: info.ModTime(),
|
||||
size: size,
|
||||
})
|
||||
totalSize += size
|
||||
}
|
||||
|
||||
if totalSize <= maxBytes {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Sort oldest first
|
||||
sortReposByModTime(repos)
|
||||
|
||||
for _, repo := range repos {
|
||||
if totalSize <= maxBytes {
|
||||
break
|
||||
}
|
||||
if err := os.RemoveAll(repo.path); err != nil {
|
||||
continue
|
||||
}
|
||||
totalSize -= repo.size
|
||||
result.RemovedCount++
|
||||
result.FreedBytes += repo.size
|
||||
result.RemovedPaths = append(result.RemovedPaths, repo.name)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetCacheStats returns current cache statistics.
|
||||
func GetCacheStats(reposDir string) (count int, totalBytes int64) {
|
||||
entries, err := os.ReadDir(reposDir)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
totalBytes += dirSize(filepath.Join(reposDir, entry.Name()))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func sortReposByModTime(repos []repoEntry) {
|
||||
for i := 0; i < len(repos); i++ {
|
||||
for j := i + 1; j < len(repos); j++ {
|
||||
if repos[j].modTime.Before(repos[i].modTime) {
|
||||
repos[i], repos[j] = repos[j], repos[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+610
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// Diff Viewer using diff2html
|
||||
const DiffViewer = {
|
||||
init(containerId) {
|
||||
this.container = document.getElementById(containerId);
|
||||
if (!this.container) {
|
||||
console.error('Diff container not found:', containerId);
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
||||
// Render unified diff string
|
||||
renderDiff(diffString, options = {}) {
|
||||
if (!this.container) return;
|
||||
|
||||
const config = {
|
||||
drawFileList: options.drawFileList !== false,
|
||||
fileListToggle: options.fileListToggle !== false,
|
||||
fileListStartVisible: options.fileListStartVisible !== false,
|
||||
fileContentToggle: options.fileContentToggle !== false,
|
||||
matching: options.matching || 'lines',
|
||||
outputFormat: options.outputFormat || 'side-by-side',
|
||||
synchronisedScroll: true,
|
||||
highlight: true,
|
||||
renderNothingWhenEmpty: false,
|
||||
};
|
||||
|
||||
// Use diff2html to render
|
||||
const diffHtml = Diff2Html.html(diffString, config);
|
||||
this.container.innerHTML = diffHtml;
|
||||
|
||||
// Add syntax highlighting
|
||||
this.container.querySelectorAll('pre code').forEach(block => {
|
||||
if (window.hljs) {
|
||||
hljs.highlightElement(block);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Render per-file diffs
|
||||
renderFiles(files, options = {}) {
|
||||
if (!this.container) return;
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
this.container.innerHTML = '<div class="text-gray-500 p-4">No changes</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Combine all patches
|
||||
const combinedDiff = files.map(f => f.patch).join('\n');
|
||||
this.renderDiff(combinedDiff, options);
|
||||
},
|
||||
|
||||
// Load and render diff from API
|
||||
async loadDiff(repoId, base, head, options = {}) {
|
||||
if (!this.container) return;
|
||||
|
||||
this.container.innerHTML = '<div class="text-center p-8"><div class="spinner inline-block"></div><p class="mt-2 text-gray-500">Loading diff...</p></div>';
|
||||
|
||||
try {
|
||||
const url = `/api/repos/${repoId}/diff?base=${encodeURIComponent(base)}&head=${encodeURIComponent(head)}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error('Failed to load diff');
|
||||
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.diff) {
|
||||
this.renderDiff(data.diff, options);
|
||||
} else if (Array.isArray(data)) {
|
||||
this.renderFiles(data, options);
|
||||
}
|
||||
} catch (err) {
|
||||
this.container.innerHTML = `<div class="text-red-500 p-4">Error loading diff: ${err.message}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
// Load per-file diffs
|
||||
async loadFiles(repoId, base, head) {
|
||||
if (!this.container) return;
|
||||
|
||||
this.container.innerHTML = '<div class="text-center p-8"><div class="spinner inline-block"></div><p class="mt-2 text-gray-500">Loading files...</p></div>';
|
||||
|
||||
try {
|
||||
const url = `/api/repos/${repoId}/diff?base=${encodeURIComponent(base)}&head=${encodeURIComponent(head)}&per_file=true`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error('Failed to load diff');
|
||||
|
||||
const files = await resp.json();
|
||||
this.renderFiles(files);
|
||||
} catch (err) {
|
||||
this.container.innerHTML = `<div class="text-red-500 p-4">Error: ${err.message}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
// Switch view mode (side-by-side or line-by-line)
|
||||
setViewMode(mode) {
|
||||
if (!this.container) return;
|
||||
|
||||
const config = {
|
||||
drawFileList: true,
|
||||
outputFormat: mode === 'unified' ? 'line-by-line' : 'side-by-side',
|
||||
};
|
||||
|
||||
// Re-render with existing diff content
|
||||
const existingDiff = this.container.querySelector('.d2h-diff-table');
|
||||
if (existingDiff) {
|
||||
// Get the raw diff from the container's data
|
||||
const rawDiff = this.container.dataset.rawDiff;
|
||||
if (rawDiff) {
|
||||
this.renderDiff(rawDiff, config);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Toggle file list visibility
|
||||
toggleFileList() {
|
||||
const fileList = this.container.querySelector('.d2h-file-list');
|
||||
if (fileList) {
|
||||
fileList.style.display = fileList.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
},
|
||||
|
||||
// Expand/collapse all files
|
||||
toggleAllFiles(expand) {
|
||||
const fileContents = this.container.querySelectorAll('.d2h-file-wrapper');
|
||||
fileContents.forEach(file => {
|
||||
const content = file.querySelector('.d2h-file-diff');
|
||||
if (content) {
|
||||
content.style.display = expand ? 'block' : 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Export for use
|
||||
window.DiffViewer = DiffViewer;
|
||||
@@ -0,0 +1,239 @@
|
||||
// Git Graph visualization using D3.js
|
||||
const GitGraph = {
|
||||
init(containerId, repoId) {
|
||||
this.container = document.getElementById(containerId);
|
||||
this.repoId = repoId;
|
||||
this.commits = [];
|
||||
this.refs = [];
|
||||
this.edges = [];
|
||||
|
||||
if (!this.container) {
|
||||
console.error('Graph container not found:', containerId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.load();
|
||||
},
|
||||
|
||||
async load() {
|
||||
try {
|
||||
const resp = await fetch(`/api/repos/${this.repoId}/graph`);
|
||||
if (!resp.ok) throw new Error('Failed to load graph');
|
||||
const data = await resp.json();
|
||||
|
||||
this.commits = data.commits || [];
|
||||
this.refs = data.refs || [];
|
||||
this.edges = data.edges || [];
|
||||
|
||||
this.render();
|
||||
} catch (err) {
|
||||
this.container.innerHTML = `<div class="text-red-500 p-4">Error loading graph: ${err.message}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
render() {
|
||||
if (this.commits.length === 0) {
|
||||
this.container.innerHTML = '<div class="text-gray-500 p-4">No commits found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear container
|
||||
this.container.innerHTML = '';
|
||||
|
||||
const width = this.container.clientWidth || 800;
|
||||
const nodeHeight = 40;
|
||||
const height = Math.max(400, this.commits.length * nodeHeight + 60);
|
||||
const margin = { top: 20, right: 120, bottom: 20, left: 60 };
|
||||
|
||||
const svg = d3.select(this.container)
|
||||
.append('svg')
|
||||
.attr('width', width)
|
||||
.attr('height', height);
|
||||
|
||||
const g = svg.append('g')
|
||||
.attr('transform', `translate(${margin.left},${margin.top})`);
|
||||
|
||||
// Build node positions
|
||||
const nodeMap = {};
|
||||
const laneWidth = 24;
|
||||
|
||||
// Assign lanes to commits (simple algorithm: each branch gets its own lane)
|
||||
const lanes = this.assignLanes();
|
||||
|
||||
this.commits.forEach((commit, i) => {
|
||||
nodeMap[commit.hash] = {
|
||||
x: (lanes[commit.hash] || 0) * laneWidth + 20,
|
||||
y: i * nodeHeight + 20,
|
||||
commit
|
||||
};
|
||||
});
|
||||
|
||||
// Draw edges
|
||||
g.selectAll('.edge')
|
||||
.data(this.edges.filter(e => nodeMap[e.source] && nodeMap[e.target]))
|
||||
.enter()
|
||||
.append('path')
|
||||
.attr('class', 'edge')
|
||||
.attr('d', d => {
|
||||
const s = nodeMap[d.source];
|
||||
const t = nodeMap[d.target];
|
||||
if (!s || !t) return '';
|
||||
const midY = (s.y + t.y) / 2;
|
||||
return `M ${s.x} ${s.y} C ${s.x} ${midY}, ${t.x} ${midY}, ${t.x} ${t.y}`;
|
||||
})
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke', '#94a3b8')
|
||||
.attr('stroke-width', 2);
|
||||
|
||||
// Draw commit nodes
|
||||
const nodes = g.selectAll('.node')
|
||||
.data(this.commits)
|
||||
.enter()
|
||||
.append('g')
|
||||
.attr('class', 'node')
|
||||
.attr('transform', (d, i) => `translate(${nodeMap[d.hash]?.x || 20},${nodeMap[d.hash]?.y || i * nodeHeight + 20})`);
|
||||
|
||||
nodes.append('circle')
|
||||
.attr('r', 6)
|
||||
.attr('fill', '#3b82f6')
|
||||
.attr('stroke', '#1e40af')
|
||||
.attr('stroke-width', 2);
|
||||
|
||||
// Add commit message
|
||||
nodes.append('text')
|
||||
.attr('x', 16)
|
||||
.attr('y', 4)
|
||||
.text(d => d.message.substring(0, 60) + (d.message.length > 60 ? '...' : ''))
|
||||
.attr('font-size', '12px')
|
||||
.attr('fill', '#374151');
|
||||
|
||||
// Add short hash
|
||||
nodes.append('text')
|
||||
.attr('x', 16)
|
||||
.attr('y', -8)
|
||||
.text(d => d.short_hash)
|
||||
.attr('font-size', '10px')
|
||||
.attr('fill', '#6b7280')
|
||||
.attr('font-family', 'monospace');
|
||||
|
||||
// Draw refs
|
||||
const refGroups = g.selectAll('.ref')
|
||||
.data(this.refs)
|
||||
.enter()
|
||||
.append('g')
|
||||
.attr('class', 'ref');
|
||||
|
||||
refGroups.each((ref, i, elements) => {
|
||||
const commitNode = nodeMap[ref.hash];
|
||||
if (!commitNode) return;
|
||||
|
||||
const group = d3.select(elements[i]);
|
||||
const isTag = ref.is_tag;
|
||||
const color = isTag ? '#10b981' : (ref.is_head ? '#8b5cf6' : '#3b82f6');
|
||||
|
||||
const bbox = { x: commitNode.x + 30, y: commitNode.y - 10 };
|
||||
|
||||
group.append('rect')
|
||||
.attr('x', bbox.x)
|
||||
.attr('y', bbox.y)
|
||||
.attr('rx', 4)
|
||||
.attr('ry', 4)
|
||||
.attr('fill', color)
|
||||
.attr('opacity', 0.1)
|
||||
.attr('stroke', color)
|
||||
.attr('stroke-width', 1);
|
||||
|
||||
const text = group.append('text')
|
||||
.attr('x', bbox.x + 6)
|
||||
.attr('y', bbox.y + 14)
|
||||
.text(ref.name)
|
||||
.attr('font-size', '11px')
|
||||
.attr('fill', color)
|
||||
.attr('font-weight', '500');
|
||||
|
||||
// Size rect to text
|
||||
const textBBox = text.node().getBBox();
|
||||
group.select('rect')
|
||||
.attr('width', textBBox.width + 12)
|
||||
.attr('height', textBBox.height + 6);
|
||||
});
|
||||
|
||||
// Add hover effect
|
||||
nodes.on('mouseover', function(event, d) {
|
||||
d3.select(this).select('circle')
|
||||
.attr('r', 8)
|
||||
.attr('fill', '#2563eb');
|
||||
}).on('mouseout', function() {
|
||||
d3.select(this).select('circle')
|
||||
.attr('r', 6)
|
||||
.attr('fill', '#3b82f6');
|
||||
});
|
||||
},
|
||||
|
||||
assignLanes() {
|
||||
// Simple lane assignment: find branches and assign lanes
|
||||
const lanes = {};
|
||||
const branchHeads = {};
|
||||
|
||||
// Find branch head commits
|
||||
this.refs.forEach(ref => {
|
||||
if (!ref.is_tag) {
|
||||
branchHeads[ref.hash] = ref.name;
|
||||
}
|
||||
});
|
||||
|
||||
// BFS from branch heads to assign lanes
|
||||
let nextLane = 0;
|
||||
const visited = new Set();
|
||||
|
||||
// First assign HEAD branch
|
||||
const headRef = this.refs.find(r => r.is_head);
|
||||
if (headRef) {
|
||||
const queue = [headRef.hash];
|
||||
while (queue.length > 0) {
|
||||
const hash = queue.shift();
|
||||
if (visited.has(hash)) continue;
|
||||
visited.add(hash);
|
||||
lanes[hash] = 0;
|
||||
|
||||
// Find children (commits that have this as parent)
|
||||
const children = this.edges.filter(e => e.target === hash).map(e => e.source);
|
||||
children.forEach(child => {
|
||||
if (!visited.has(child)) queue.push(child);
|
||||
});
|
||||
}
|
||||
nextLane = 1;
|
||||
}
|
||||
|
||||
// Assign lanes to other branches
|
||||
this.refs.filter(r => !r.is_head && !r.is_tag).forEach(ref => {
|
||||
if (visited.has(ref.hash)) return;
|
||||
|
||||
const queue = [ref.hash];
|
||||
while (queue.length > 0) {
|
||||
const hash = queue.shift();
|
||||
if (visited.has(hash)) continue;
|
||||
visited.add(hash);
|
||||
lanes[hash] = nextLane;
|
||||
|
||||
const children = this.edges.filter(e => e.target === hash).map(e => e.source);
|
||||
children.forEach(child => {
|
||||
if (!visited.has(child)) queue.push(child);
|
||||
});
|
||||
}
|
||||
nextLane++;
|
||||
});
|
||||
|
||||
// Assign any remaining commits
|
||||
this.commits.forEach(commit => {
|
||||
if (!lanes[commit.hash]) {
|
||||
lanes[commit.hash] = 0;
|
||||
}
|
||||
});
|
||||
|
||||
return lanes;
|
||||
}
|
||||
};
|
||||
|
||||
// Export for use
|
||||
window.GitGraph = GitGraph;
|
||||
+166
-68
@@ -1,102 +1,200 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" class="h-full">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
{{template "head" .}}
|
||||
<body class="h-full bg-gray-50 text-gray-900">
|
||||
<div class="min-h-full flex flex-col">
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
{{template "nav" .}}
|
||||
<main class="flex-1">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 class="text-2xl font-bold mb-6">生成 PR 描述</h1>
|
||||
<!-- Ref Selection -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">选择范围</h2>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
|
||||
<main class="max-w-4xl mx-auto px-4 py-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-6">生成 PR 描述</h1>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Base (基准)</label>
|
||||
<input type="text" id="base-ref" placeholder="main"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Base 分支</label>
|
||||
<select id="base-ref" class="w-full border rounded-md px-3 py-2" required>
|
||||
<option value="">加载中...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Head (目标)</label>
|
||||
<input type="text" id="head-ref" placeholder="feature/branch"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Head 分支</label>
|
||||
<select id="head-ref" class="w-full border rounded-md px-3 py-2" required>
|
||||
<option value="">加载中...</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button id="generate-btn" onclick="generatePR()"
|
||||
class="mt-4 bg-green-600 text-white px-5 py-2 rounded-md text-sm font-medium hover:bg-green-700">
|
||||
<button onclick="generate()" id="btn-generate" class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700">
|
||||
生成 PR 描述
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Output -->
|
||||
<div id="pr-output" class="hidden bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div id="output" class="bg-white rounded-lg shadow-md p-6 hidden">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-lg font-semibold">PR 描述</h2>
|
||||
<button onclick="copyMarkdown()" class="text-sm text-indigo-600 hover:text-indigo-800">📋 复制 Markdown</button>
|
||||
<button onclick="copyMarkdown()" class="px-3 py-1 bg-gray-100 text-gray-700 rounded hover:bg-gray-200">
|
||||
复制 Markdown
|
||||
</button>
|
||||
</div>
|
||||
<div id="pr-title" class="text-xl font-bold mb-2"></div>
|
||||
<div id="pr-type" class="inline-block bg-indigo-100 text-indigo-700 text-xs px-2 py-1 rounded mb-4"></div>
|
||||
<div id="pr-summary" class="text-gray-700 mb-4"></div>
|
||||
<div id="pr-details" class="prose prose-sm max-w-none"></div>
|
||||
<div id="pr-impact" class="mt-4 p-3 bg-yellow-50 rounded text-sm text-yellow-800"></div>
|
||||
|
||||
<div id="output-content">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-500">标题</label>
|
||||
<p id="pr-title" class="text-lg font-semibold text-gray-900"></p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-500">类型</label>
|
||||
<span id="pr-type" class="inline-block px-2 py-1 bg-blue-100 text-blue-800 rounded text-sm"></span>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-500">摘要</label>
|
||||
<p id="pr-summary" class="text-gray-700"></p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-500">详细说明</label>
|
||||
<ul id="pr-details" class="list-disc list-inside text-gray-700 space-y-1"></ul>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-500">影响</label>
|
||||
<p id="pr-impact" class="text-gray-700"></p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 p-4 bg-gray-50 rounded-md">
|
||||
<label class="block text-sm font-medium text-gray-500 mb-2">Markdown 预览</label>
|
||||
<pre id="pr-markdown" class="whitespace-pre-wrap text-sm text-gray-800 font-mono"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div id="loading" class="text-center py-8 hidden">
|
||||
<div class="spinner inline-block"></div>
|
||||
<p class="mt-2 text-gray-500">正在生成 PR 描述...</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{{template "footer" .}}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function generatePR() {
|
||||
const base = document.getElementById('base-ref').value.trim();
|
||||
const head = document.getElementById('head-ref').value.trim();
|
||||
if (!base || !head) { alert('请填写 base 和 head'); return; }
|
||||
const btn = document.getElementById('generate-btn');
|
||||
const output = document.getElementById('pr-output');
|
||||
btn.disabled = true; btn.textContent = '生成中...';
|
||||
output.classList.remove('hidden');
|
||||
const repoId = {{.ID}};
|
||||
|
||||
// Load refs on page load
|
||||
async function loadRefs() {
|
||||
try {
|
||||
const resp = await fetch('/api/repos/{{.ID}}/generate', {
|
||||
const resp = await fetch(`/api/repos/${repoId}/refs`);
|
||||
if (!resp.ok) throw new Error('Failed to load refs');
|
||||
const refs = await resp.json();
|
||||
|
||||
const baseSelect = document.getElementById('base-ref');
|
||||
const headSelect = document.getElementById('head-ref');
|
||||
baseSelect.innerHTML = '<option value="">选择分支</option>';
|
||||
headSelect.innerHTML = '<option value="">选择分支</option>';
|
||||
|
||||
const branches = refs.filter(r => !r.is_tag);
|
||||
|
||||
branches.forEach(ref => {
|
||||
const opt1 = document.createElement('option');
|
||||
opt1.value = ref.name;
|
||||
opt1.textContent = ref.name + (ref.is_head ? ' (HEAD)' : '');
|
||||
baseSelect.appendChild(opt1);
|
||||
|
||||
const opt2 = document.createElement('option');
|
||||
opt2.value = ref.name;
|
||||
opt2.textContent = ref.name + (ref.is_head ? ' (HEAD)' : '');
|
||||
headSelect.appendChild(opt2);
|
||||
});
|
||||
|
||||
// Auto-select main/master as base, HEAD as head
|
||||
const mainBranch = branches.find(b => b.name === 'main' || b.name === 'master');
|
||||
const headBranch = branches.find(r => r.is_head);
|
||||
|
||||
if (mainBranch) baseSelect.value = mainBranch.name;
|
||||
if (headBranch) headSelect.value = headBranch.name;
|
||||
} catch (err) {
|
||||
console.error('Load refs error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
const baseRef = document.getElementById('base-ref').value;
|
||||
const headRef = document.getElementById('head-ref').value;
|
||||
|
||||
if (!baseRef || !headRef) {
|
||||
alert('请选择 Base 和 Head 分支');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('btn-generate');
|
||||
const output = document.getElementById('output');
|
||||
const loading = document.getElementById('loading');
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '生成中...';
|
||||
output.classList.remove('hidden');
|
||||
document.getElementById('output-content').classList.add('hidden');
|
||||
loading.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/repos/${repoId}/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({base, head})
|
||||
body: JSON.stringify({ base_ref: baseRef, head_ref: headRef })
|
||||
});
|
||||
|
||||
if (!resp.ok) throw new Error('生成失败');
|
||||
|
||||
const data = await resp.json();
|
||||
|
||||
document.getElementById('pr-title').textContent = data.title || '';
|
||||
document.getElementById('pr-type').textContent = data.type || '';
|
||||
document.getElementById('pr-summary').textContent = data.summary || '';
|
||||
document.getElementById('pr-impact').textContent = data.impact || '';
|
||||
|
||||
const detailsList = document.getElementById('pr-details');
|
||||
detailsList.innerHTML = '';
|
||||
if (data.details && Array.isArray(data.details)) {
|
||||
data.details.forEach(item => {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = item;
|
||||
detailsList.appendChild(li);
|
||||
});
|
||||
if (!resp.ok) { const data = await resp.json(); throw new Error(data.error || '生成失败'); }
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '', eventType = '';
|
||||
while (true) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, {stream: true});
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) eventType = line.slice(7).trim();
|
||||
else if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
if (eventType === 'title') document.getElementById('pr-title').textContent = data.content;
|
||||
if (eventType === 'type') document.getElementById('pr-type').textContent = data.content;
|
||||
if (eventType === 'summary') document.getElementById('pr-summary').textContent = data.content;
|
||||
if (eventType === 'detail') document.getElementById('pr-details').innerHTML = data.content;
|
||||
if (eventType === 'impact') document.getElementById('pr-impact').textContent = '影响范围: ' + data.content;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('pr-markdown').textContent = data.markdown || '';
|
||||
|
||||
loading.classList.add('hidden');
|
||||
document.getElementById('output-content').classList.remove('hidden');
|
||||
} catch (err) {
|
||||
document.getElementById('pr-summary').textContent = '错误: ' + err.message;
|
||||
alert('生成失败: ' + err.message);
|
||||
output.classList.add('hidden');
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = '生成 PR 描述';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '生成 PR 描述';
|
||||
}
|
||||
}
|
||||
|
||||
function copyMarkdown() {
|
||||
const title = document.getElementById('pr-title').textContent;
|
||||
const type = document.getElementById('pr-type').textContent;
|
||||
const summary = document.getElementById('pr-summary').textContent;
|
||||
const details = document.getElementById('pr-details').innerText;
|
||||
const impact = document.getElementById('pr-impact').textContent;
|
||||
const md = '# ' + title + '\n\n**Type:** ' + type + '\n\n## Summary\n' + summary + '\n\n## Details\n' + details + '\n\n## Impact\n' + impact;
|
||||
navigator.clipboard.writeText(md);
|
||||
const markdown = document.getElementById('pr-markdown').textContent;
|
||||
navigator.clipboard.writeText(markdown).then(() => {
|
||||
alert('已复制到剪贴板');
|
||||
}).catch(() => {
|
||||
// Fallback
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = markdown;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
alert('已复制到剪贴板');
|
||||
});
|
||||
}
|
||||
|
||||
loadRefs();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+196
-28
@@ -1,51 +1,219 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" class="h-full">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
{{template "head" .}}
|
||||
<body class="h-full bg-gray-50 text-gray-900">
|
||||
<div class="min-h-full flex flex-col">
|
||||
<link rel="stylesheet" href="/static/lib/diff2html.min.css">
|
||||
<script src="/static/lib/d3.min.js"></script>
|
||||
<script src="/static/lib/diff2html.min.js"></script>
|
||||
<script src="/static/lib/highlight.min.js"></script>
|
||||
<script src="/static/js/graph.js"></script>
|
||||
<script src="/static/js/diff-viewer.js"></script>
|
||||
<style>
|
||||
.graph-container {
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
min-height: 300px;
|
||||
overflow: auto;
|
||||
}
|
||||
.graph-container svg {
|
||||
display: block;
|
||||
}
|
||||
.ref-tag {
|
||||
cursor: pointer;
|
||||
}
|
||||
.ref-tag:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
{{template "nav" .}}
|
||||
<main class="flex-1">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
<main class="max-w-7xl mx-auto px-4 py-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold">仓库详情</h1>
|
||||
<div class="flex gap-3">
|
||||
<a href="/repo/{{.ID}}/generate"
|
||||
class="bg-green-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-green-700">
|
||||
<h1 class="text-2xl font-bold text-gray-900">
|
||||
<span id="repo-url" class="text-gray-500 text-lg font-normal"></span>
|
||||
</h1>
|
||||
<div class="flex gap-2">
|
||||
<a id="btn-generate" href="#" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
||||
生成 PR 描述
|
||||
</a>
|
||||
<a href="/repo/{{.ID}}/review"
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-blue-700">
|
||||
<a id="btn-review" href="#" class="px-4 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700">
|
||||
AI 代码审查
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Repo Info -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
|
||||
<div id="repo-info" class="text-sm text-gray-500">加载中...</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="bg-white rounded-lg shadow-md">
|
||||
<div class="border-b border-gray-200">
|
||||
<nav class="flex -mb-px">
|
||||
<button onclick="showTab('graph')" id="tab-graph" class="px-6 py-3 text-sm font-medium border-b-2 border-blue-500 text-blue-600">
|
||||
Git Graph
|
||||
</button>
|
||||
<button onclick="showTab('diff')" id="tab-diff" class="px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300">
|
||||
Diff 视图
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
<!-- Git Graph -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Git Graph</h2>
|
||||
<div id="git-graph" class="overflow-auto" style="min-height: 400px;">
|
||||
<p class="text-sm text-gray-400 text-center py-10">Git Graph 将在 Phase 3 实现</p>
|
||||
|
||||
<!-- Graph Tab -->
|
||||
<div id="panel-graph" class="p-4">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-600">Base:</label>
|
||||
<select id="graph-base" class="border rounded px-2 py-1 text-sm">
|
||||
<option value="">选择分支/标签</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-600">Head:</label>
|
||||
<select id="graph-head" class="border rounded px-2 py-1 text-sm">
|
||||
<option value="">选择分支/标签</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="git-graph" class="graph-container p-4"></div>
|
||||
</div>
|
||||
|
||||
<!-- Diff Tab -->
|
||||
<div id="panel-diff" class="p-4 hidden">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-600">Base:</label>
|
||||
<select id="diff-base" class="border rounded px-2 py-1 text-sm">
|
||||
<option value="">选择分支/标签</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-600">Head:</label>
|
||||
<select id="diff-head" class="border rounded px-2 py-1 text-sm">
|
||||
<option value="">选择分支/标签</option>
|
||||
</select>
|
||||
</div>
|
||||
<button onclick="loadDiff()" class="px-3 py-1 bg-blue-600 text-white rounded text-sm hover:bg-blue-700">
|
||||
查看 Diff
|
||||
</button>
|
||||
<div class="flex gap-1 ml-auto">
|
||||
<button onclick="setDiffView('side-by-side')" class="px-2 py-1 text-xs bg-gray-100 rounded hover:bg-gray-200">并排</button>
|
||||
<button onclick="setDiffView('unified')" class="px-2 py-1 text-xs bg-gray-100 rounded hover:bg-gray-200">统一</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="diff-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{{template "footer" .}}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
fetch('/api/repos').then(r => r.json()).then(repos => {
|
||||
const repo = repos.find(r => r.id == {{.ID}});
|
||||
const el = document.getElementById('repo-info');
|
||||
const repoId = {{.ID}};
|
||||
let currentRefs = [];
|
||||
|
||||
// Load repo info and refs
|
||||
async function init() {
|
||||
try {
|
||||
// Load refs
|
||||
const refsResp = await fetch(`/api/repos/${repoId}/refs`);
|
||||
if (refsResp.ok) {
|
||||
currentRefs = await refsResp.json();
|
||||
populateRefSelects(currentRefs);
|
||||
}
|
||||
|
||||
// Load repo info
|
||||
const reposResp = await fetch('/api/repos');
|
||||
if (reposResp.ok) {
|
||||
const repos = await reposResp.json();
|
||||
const repo = repos.find(r => r.id === repoId);
|
||||
if (repo) {
|
||||
el.innerHTML = '<p><strong>URL:</strong> ' + repo.url + '</p>' +
|
||||
'<p class="mt-1"><strong>大小:</strong> ' + (repo.size_bytes / 1024 / 1024).toFixed(1) + ' MB</p>' +
|
||||
'<p class="mt-1"><strong>克隆时间:</strong> ' + repo.cloned_at + '</p>';
|
||||
} else {
|
||||
el.innerHTML = '<p class="text-red-500">仓库未找到</p>';
|
||||
document.getElementById('repo-url').textContent = repo.url;
|
||||
document.getElementById('btn-generate').href = `/repo/${repoId}/generate`;
|
||||
document.getElementById('btn-review').href = `/repo/${repoId}/review`;
|
||||
}
|
||||
}
|
||||
|
||||
// Init graph
|
||||
GitGraph.init('git-graph', repoId);
|
||||
DiffViewer.init('diff-container');
|
||||
} catch (err) {
|
||||
console.error('Init error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function populateRefSelects(refs) {
|
||||
const selects = ['graph-base', 'graph-head', 'diff-base', 'diff-head'];
|
||||
|
||||
selects.forEach(selectId => {
|
||||
const select = document.getElementById(selectId);
|
||||
select.innerHTML = '<option value="">选择分支/标签</option>';
|
||||
|
||||
// Branches first
|
||||
const branches = refs.filter(r => !r.is_tag);
|
||||
const tags = refs.filter(r => r.is_tag);
|
||||
|
||||
if (branches.length > 0) {
|
||||
const group = document.createElement('optgroup');
|
||||
group.label = '分支';
|
||||
branches.forEach(ref => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = ref.name;
|
||||
opt.textContent = ref.name + (ref.is_head ? ' (HEAD)' : '');
|
||||
group.appendChild(opt);
|
||||
});
|
||||
select.appendChild(group);
|
||||
}
|
||||
|
||||
if (tags.length > 0) {
|
||||
const group = document.createElement('optgroup');
|
||||
group.label = '标签';
|
||||
tags.forEach(ref => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = ref.name;
|
||||
opt.textContent = ref.name;
|
||||
group.appendChild(opt);
|
||||
});
|
||||
select.appendChild(group);
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-select main/master as base, HEAD as head
|
||||
const mainBranch = branches.find(b => b.name === 'main' || b.name === 'master');
|
||||
const headBranch = refs.find(r => r.is_head);
|
||||
|
||||
selects.forEach(selectId => {
|
||||
const select = document.getElementById(selectId);
|
||||
if (selectId.includes('base') && mainBranch) {
|
||||
select.value = mainBranch.name;
|
||||
} else if (selectId.includes('head') && headBranch) {
|
||||
select.value = headBranch.name;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showTab(tab) {
|
||||
document.getElementById('panel-graph').classList.toggle('hidden', tab !== 'graph');
|
||||
document.getElementById('panel-diff').classList.toggle('hidden', tab !== 'diff');
|
||||
document.getElementById('tab-graph').classList.toggle('border-blue-500', tab === 'graph');
|
||||
document.getElementById('tab-graph').classList.toggle('text-blue-600', tab === 'graph');
|
||||
document.getElementById('tab-diff').classList.toggle('border-blue-500', tab === 'diff');
|
||||
document.getElementById('tab-diff').classList.toggle('text-blue-600', tab === 'diff');
|
||||
}
|
||||
|
||||
function loadDiff() {
|
||||
const base = document.getElementById('diff-base').value;
|
||||
const head = document.getElementById('diff-head').value;
|
||||
if (!base || !head) {
|
||||
alert('请选择 Base 和 Head 分支');
|
||||
return;
|
||||
}
|
||||
DiffViewer.loadDiff(repoId, base, head);
|
||||
}
|
||||
|
||||
function setDiffView(mode) {
|
||||
DiffViewer.setViewMode(mode);
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+172
-81
@@ -1,113 +1,204 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" class="h-full">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
{{template "head" .}}
|
||||
<body class="h-full bg-gray-50 text-gray-900">
|
||||
<div class="min-h-full flex flex-col">
|
||||
<link rel="stylesheet" href="/static/lib/diff2html.min.css">
|
||||
<script src="/static/lib/diff2html.min.js"></script>
|
||||
<script src="/static/lib/highlight.min.js"></script>
|
||||
<script src="/static/js/diff-viewer.js"></script>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
{{template "nav" .}}
|
||||
<main class="flex-1">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 class="text-2xl font-bold mb-6">AI 代码审查</h1>
|
||||
<!-- Ref Selection + Top-N -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">审查配置</h2>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
|
||||
<main class="max-w-6xl mx-auto px-4 py-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-6">AI 代码审查</h1>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<div class="grid grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Base</label>
|
||||
<input type="text" id="base-ref" placeholder="main"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Base 分支</label>
|
||||
<select id="base-ref" class="w-full border rounded-md px-3 py-2" required>
|
||||
<option value="">加载中...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Head</label>
|
||||
<input type="text" id="head-ref" placeholder="feature/branch"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Head 分支</label>
|
||||
<select id="head-ref" class="w-full border rounded-md px-3 py-2" required>
|
||||
<option value="">加载中...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Top-N 文件数</label>
|
||||
<input type="number" id="top-n" value="{{.TopN}}" min="0"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
|
||||
<p class="text-xs text-gray-400 mt-1">0 = 分析全部文件</p>
|
||||
<input type="number" id="top-n" value="20" min="1" max="100" class="w-full border rounded-md px-3 py-2">
|
||||
</div>
|
||||
</div>
|
||||
<button id="review-btn" onclick="startReview()"
|
||||
class="mt-4 bg-blue-600 text-white px-5 py-2 rounded-md text-sm font-medium hover:bg-blue-700">
|
||||
<button onclick="startReview()" id="btn-review" class="w-full bg-purple-600 text-white py-2 px-4 rounded-md hover:bg-purple-700">
|
||||
开始审查
|
||||
</button>
|
||||
</div>
|
||||
<!-- Review Output -->
|
||||
<div id="review-output" class="hidden space-y-6">
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h2 class="text-lg font-semibold mb-3">整体评估</h2>
|
||||
<div id="review-summary" class="prose prose-sm max-w-none text-gray-700"></div>
|
||||
|
||||
<!-- Progress -->
|
||||
<div id="progress" class="bg-white rounded-lg shadow-md p-6 mb-6 hidden">
|
||||
<h3 class="font-semibold text-gray-700 mb-2">审查进度</h3>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2.5 mb-2">
|
||||
<div id="progress-bar" class="bg-purple-600 h-2.5 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
<div id="file-reviews" class="space-y-4"></div>
|
||||
<p id="progress-text" class="text-sm text-gray-500">准备中...</p>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div id="results" class="hidden">
|
||||
<!-- Summary -->
|
||||
<div id="summary" class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">审查总结</h2>
|
||||
<div id="summary-content" class="text-gray-700"></div>
|
||||
</div>
|
||||
|
||||
<!-- File Reviews -->
|
||||
<div id="file-reviews" class="space-y-4">
|
||||
<!-- Dynamically populated -->
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{{template "footer" .}}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function startReview() {
|
||||
const base = document.getElementById('base-ref').value.trim();
|
||||
const head = document.getElementById('head-ref').value.trim();
|
||||
const topN = parseInt(document.getElementById('top-n').value) || 0;
|
||||
if (!base || !head) { alert('请填写 base 和 head'); return; }
|
||||
const btn = document.getElementById('review-btn');
|
||||
const output = document.getElementById('review-output');
|
||||
const fileReviews = document.getElementById('file-reviews');
|
||||
btn.disabled = true; btn.textContent = '审查中...';
|
||||
output.classList.remove('hidden');
|
||||
fileReviews.innerHTML = '';
|
||||
document.getElementById('review-summary').innerHTML = '';
|
||||
let currentFileEl = null;
|
||||
const repoId = {{.ID}};
|
||||
|
||||
// Load refs on page load
|
||||
async function loadRefs() {
|
||||
try {
|
||||
const resp = await fetch('/api/repos/{{.ID}}/review', {
|
||||
const resp = await fetch(`/api/repos/${repoId}/refs`);
|
||||
if (!resp.ok) throw new Error('Failed to load refs');
|
||||
const refs = await resp.json();
|
||||
|
||||
const baseSelect = document.getElementById('base-ref');
|
||||
const headSelect = document.getElementById('head-ref');
|
||||
baseSelect.innerHTML = '<option value="">选择分支</option>';
|
||||
headSelect.innerHTML = '<option value="">选择分支</option>';
|
||||
|
||||
const branches = refs.filter(r => !r.is_tag);
|
||||
|
||||
branches.forEach(ref => {
|
||||
const opt1 = document.createElement('option');
|
||||
opt1.value = ref.name;
|
||||
opt1.textContent = ref.name + (ref.is_head ? ' (HEAD)' : '');
|
||||
baseSelect.appendChild(opt1);
|
||||
|
||||
const opt2 = document.createElement('option');
|
||||
opt2.value = ref.name;
|
||||
opt2.textContent = ref.name + (ref.is_head ? ' (HEAD)' : '');
|
||||
headSelect.appendChild(opt2);
|
||||
});
|
||||
|
||||
// Auto-select main/master as base, HEAD as head
|
||||
const mainBranch = branches.find(b => b.name === 'main' || b.name === 'master');
|
||||
const headBranch = branches.find(r => r.is_head);
|
||||
|
||||
if (mainBranch) baseSelect.value = mainBranch.name;
|
||||
if (headBranch) headSelect.value = headBranch.name;
|
||||
} catch (err) {
|
||||
console.error('Load refs error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function startReview() {
|
||||
const baseRef = document.getElementById('base-ref').value;
|
||||
const headRef = document.getElementById('head-ref').value;
|
||||
const topN = document.getElementById('top-n').value;
|
||||
|
||||
if (!baseRef || !headRef) {
|
||||
alert('请选择 Base 和 Head 分支');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('btn-review');
|
||||
const progress = document.getElementById('progress');
|
||||
const results = document.getElementById('results');
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '审查中...';
|
||||
progress.classList.remove('hidden');
|
||||
results.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/repos/${repoId}/review`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({base, head, top_n: topN})
|
||||
body: JSON.stringify({ base_ref: baseRef, head_ref: headRef, top_n: parseInt(topN) })
|
||||
});
|
||||
|
||||
if (!resp.ok) throw new Error('审查失败');
|
||||
|
||||
const data = await resp.json();
|
||||
|
||||
// Display summary
|
||||
document.getElementById('summary-content').innerHTML = data.summary || '无总结';
|
||||
|
||||
// Display file reviews
|
||||
const container = document.getElementById('file-reviews');
|
||||
container.innerHTML = '';
|
||||
|
||||
if (data.files && Array.isArray(data.files)) {
|
||||
data.files.forEach(file => {
|
||||
const card = createFileCard(file);
|
||||
container.appendChild(card);
|
||||
});
|
||||
if (!resp.ok) { const data = await resp.json(); throw new Error(data.error || '审查失败'); }
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '', eventType = '';
|
||||
while (true) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, {stream: true});
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) eventType = line.slice(7).trim();
|
||||
else if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
if (eventType === 'file_start') {
|
||||
currentFileEl = document.createElement('div');
|
||||
currentFileEl.className = 'bg-white rounded-lg shadow-sm border border-gray-200 p-6';
|
||||
currentFileEl.innerHTML = '<h3 class="font-semibold text-gray-900 mb-3">📄 ' + data.file + '</h3><div class="space-y-2"></div>';
|
||||
fileReviews.appendChild(currentFileEl);
|
||||
}
|
||||
if (eventType === 'suggestion' && currentFileEl) {
|
||||
const colors = {critical: 'red', warning: 'yellow', info: 'green'};
|
||||
const icons = {critical: '🔴', warning: '🟡', info: '🟢'};
|
||||
const c = colors[data.severity] || 'gray';
|
||||
const icon = icons[data.severity] || '⚪';
|
||||
const div = document.createElement('div');
|
||||
div.className = 'border-l-4 border-' + c + '-400 bg-' + c + '-50 px-4 py-2 rounded-r text-sm';
|
||||
div.innerHTML = '<span class="font-medium">' + icon + ' ' + data.severity + '</span><p class="mt-1 text-gray-700">' + data.content + '</p>';
|
||||
currentFileEl.querySelector('.space-y-2').appendChild(div);
|
||||
}
|
||||
if (eventType === 'file_end') currentFileEl = null;
|
||||
if (eventType === 'summary') document.getElementById('review-summary').innerHTML = data.content;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.classList.remove('hidden');
|
||||
} catch (err) {
|
||||
document.getElementById('review-summary').textContent = '错误: ' + err.message;
|
||||
alert('审查失败: ' + err.message);
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = '开始审查';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '开始审查';
|
||||
progress.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function createFileCard(file) {
|
||||
const severityColors = {
|
||||
critical: 'bg-red-100 text-red-800 border-red-200',
|
||||
warning: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
info: 'bg-green-100 text-green-800 border-green-200'
|
||||
};
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'bg-white rounded-lg shadow-md border-l-4 ' + (severityColors[file.max_severity] || severityColors.info);
|
||||
|
||||
let suggestionsHtml = '';
|
||||
if (file.suggestions && Array.isArray(file.suggestions)) {
|
||||
suggestionsHtml = file.suggestions.map(s => `
|
||||
<div class="mt-2 p-2 bg-gray-50 rounded">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="px-2 py-0.5 rounded text-xs font-medium ${severityColors[s.severity] || severityColors.info}">
|
||||
${s.severity || 'info'}
|
||||
</span>
|
||||
${s.line ? `<span class="text-xs text-gray-500">行 ${s.line}</span>` : ''}
|
||||
</div>
|
||||
<p class="text-sm text-gray-700">${s.content || ''}</p>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-medium text-gray-900">${file.filename || ''}</h3>
|
||||
<span class="px-2 py-1 rounded text-xs font-medium ${severityColors[file.max_severity] || severityColors.info}">
|
||||
${file.max_severity || 'info'}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-600">${file.summary || ''}</p>
|
||||
${suggestionsHtml}
|
||||
</div>
|
||||
`;
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
loadRefs();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user