mirror of
https://github.com/lukaszraczylo/git-velocity.git
synced 2026-06-05 22:43:56 +00:00
Use github graphql client as primary, fallback to rest client
This commit is contained in:
@@ -1,13 +1,18 @@
|
||||
module github.com/lukaszraczylo/git-velocity
|
||||
|
||||
go 1.24.0
|
||||
go 1.24.2
|
||||
|
||||
require (
|
||||
github.com/bradleyfalzon/ghinstallation/v2 v2.17.0
|
||||
github.com/charmbracelet/bubbles v0.21.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/go-git/go-git/v5 v5.16.4
|
||||
github.com/goccy/go-json v0.10.5
|
||||
github.com/google/go-github/v68 v68.0.0
|
||||
github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -15,13 +20,23 @@ require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.3 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.6.1 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.1 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.7.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/google/go-github/v75 v75.0.0 // indirect
|
||||
@@ -30,14 +45,25 @@ require (
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/kevinburke/ssh_config v1.4.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect
|
||||
github.com/skeema/knownhosts v1.3.2 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/crypto v0.46.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
)
|
||||
|
||||
@@ -9,8 +9,32 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/bradleyfalzon/ghinstallation/v2 v2.17.0 h1:SmbUK/GxpAspRjSQbB6ARvH+ArzlNzTtHydNyXUQ6zg=
|
||||
github.com/bradleyfalzon/ghinstallation/v2 v2.17.0/go.mod h1:vuD/xvJT9Y+ZVZRv4HQ42cMyPFIYqpc7AbB4Gvt/DlY=
|
||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI=
|
||||
github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.6.1 h1:/zMlAezfDzT2xy6acHBzwIfyu2ic0hgkT83UX5EY2gY=
|
||||
github.com/clipperhouse/displaywidth v0.6.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
||||
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
@@ -23,6 +47,8 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o
|
||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||
@@ -63,6 +89,20 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
||||
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
||||
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
||||
@@ -71,11 +111,17 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 h1:cYCy18SHPKRkvclm+pWm1Lk4YrREb4IOIb/YdFO0p2M=
|
||||
github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8=
|
||||
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0=
|
||||
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
|
||||
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
|
||||
@@ -91,6 +137,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
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=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
@@ -100,12 +148,16 @@ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbR
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
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-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
|
||||
+109
-78
@@ -204,7 +204,6 @@ func (a *App) collectRepoData(ctx context.Context, owner, name string, dateRange
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch commits: %w", err)
|
||||
}
|
||||
a.log(" Found %d commits", len(commits))
|
||||
|
||||
// Filter out bots
|
||||
for _, c := range commits {
|
||||
@@ -213,87 +212,65 @@ func (a *App) collectRepoData(ctx context.Context, owner, name string, dateRange
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch pull requests
|
||||
prs, err := a.client.FetchPullRequests(ctx, owner, name, dateRange.Start, dateRange.End)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch pull requests: %w", err)
|
||||
}
|
||||
a.log(" Found %d pull requests", len(prs))
|
||||
|
||||
for _, pr := range prs {
|
||||
if !a.config.IsBot(pr.Author.Login) {
|
||||
data.PullRequests = append(data.PullRequests, pr)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch reviews in parallel for all PRs (already filtered by FetchPullRequests)
|
||||
if len(prs) > 0 {
|
||||
a.log(" Fetching reviews for %d PRs in parallel...", len(prs))
|
||||
|
||||
type reviewResult struct {
|
||||
reviews []models.Review
|
||||
err error
|
||||
}
|
||||
|
||||
// Use worker pool to limit concurrent requests
|
||||
concurrency := a.config.Options.ConcurrentRequests
|
||||
if concurrency <= 0 {
|
||||
concurrency = 5
|
||||
}
|
||||
|
||||
results := make(chan reviewResult, len(prs))
|
||||
sem := make(chan struct{}, concurrency)
|
||||
|
||||
for _, pr := range prs {
|
||||
go func(prNum int) {
|
||||
sem <- struct{}{} // Acquire
|
||||
defer func() { <-sem }() // Release
|
||||
|
||||
reviews, err := a.client.FetchReviews(ctx, owner, name, prNum)
|
||||
results <- reviewResult{reviews: reviews, err: err}
|
||||
}(pr.Number)
|
||||
}
|
||||
|
||||
// Collect results
|
||||
reviewCount := 0
|
||||
for i := 0; i < len(prs); i++ {
|
||||
result := <-results
|
||||
if result.err != nil {
|
||||
continue
|
||||
// Fetch pull requests and reviews
|
||||
// Use GraphQL if available (much fewer API calls), otherwise fall back to REST
|
||||
if a.client.HasGraphQL() {
|
||||
prs, reviews, err := a.client.FetchPRsWithReviewsGraphQL(ctx, owner, name, dateRange.Start, dateRange.End)
|
||||
if err != nil {
|
||||
a.log(" Warning: GraphQL fetch failed, falling back to REST: %v", err)
|
||||
// Fall back to REST
|
||||
prs, reviews, err = a.fetchPRsAndReviewsREST(ctx, owner, name, dateRange, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range result.reviews {
|
||||
if !a.config.IsBot(r.Author.Login) {
|
||||
data.Reviews = append(data.Reviews, r)
|
||||
reviewCount++
|
||||
}
|
||||
|
||||
// Filter out bots
|
||||
for _, pr := range prs {
|
||||
if !a.config.IsBot(pr.Author.Login) {
|
||||
data.PullRequests = append(data.PullRequests, pr)
|
||||
}
|
||||
}
|
||||
for _, r := range reviews {
|
||||
if !a.config.IsBot(r.Author.Login) {
|
||||
data.Reviews = append(data.Reviews, r)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Use REST API
|
||||
if _, _, err := a.fetchPRsAndReviewsREST(ctx, owner, name, dateRange, data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch issues and comments
|
||||
// Use GraphQL if available (much fewer API calls), otherwise fall back to REST
|
||||
if a.client.HasGraphQL() {
|
||||
issues, comments, err := a.client.FetchIssuesWithCommentsGraphQL(ctx, owner, name, dateRange.Start, dateRange.End)
|
||||
if err != nil {
|
||||
a.log(" Warning: GraphQL fetch failed, falling back to REST: %v", err)
|
||||
// Fall back to REST
|
||||
if err := a.fetchIssuesAndCommentsREST(ctx, owner, name, dateRange, data); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
|
||||
// Filter out bots
|
||||
for _, issue := range issues {
|
||||
if !a.config.IsBot(issue.Author.Login) {
|
||||
data.Issues = append(data.Issues, issue)
|
||||
}
|
||||
}
|
||||
for _, comment := range comments {
|
||||
if !a.config.IsBot(comment.Author.Login) {
|
||||
data.IssueComments = append(data.IssueComments, comment)
|
||||
}
|
||||
}
|
||||
}
|
||||
a.log(" Found %d reviews across %d PRs", reviewCount, len(prs))
|
||||
}
|
||||
|
||||
// Fetch issues
|
||||
issues, err := a.client.FetchIssues(ctx, owner, name, dateRange.Start, dateRange.End)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch issues: %w", err)
|
||||
}
|
||||
a.log(" Found %d issues", len(issues))
|
||||
|
||||
for _, issue := range issues {
|
||||
if !a.config.IsBot(issue.Author.Login) {
|
||||
data.Issues = append(data.Issues, issue)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch issue comments
|
||||
issueComments, err := a.client.FetchIssueComments(ctx, owner, name, dateRange.Start, dateRange.End)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch issue comments: %w", err)
|
||||
}
|
||||
a.log(" Found %d issue comments", len(issueComments))
|
||||
|
||||
for _, comment := range issueComments {
|
||||
if !a.config.IsBot(comment.Author.Login) {
|
||||
data.IssueComments = append(data.IssueComments, comment)
|
||||
} else {
|
||||
// Use REST API
|
||||
if err := a.fetchIssuesAndCommentsREST(ctx, owner, name, dateRange, data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,3 +331,57 @@ func (a *App) fetchUserProfiles(ctx context.Context, data *models.RawData) (map[
|
||||
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
// fetchPRsAndReviewsREST fetches PRs and reviews using the REST API (fallback when GraphQL fails)
|
||||
func (a *App) fetchPRsAndReviewsREST(ctx context.Context, owner, name string, dateRange *config.ParsedDateRange, data *models.RawData) ([]models.PullRequest, []models.Review, error) {
|
||||
prs, err := a.client.FetchPullRequests(ctx, owner, name, dateRange.Start, dateRange.End)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to fetch pull requests: %w", err)
|
||||
}
|
||||
a.log(" Found %d pull requests", len(prs))
|
||||
|
||||
// Fetch reviews for each PR
|
||||
var reviews []models.Review
|
||||
for _, pr := range prs {
|
||||
prReviews, err := a.client.FetchReviews(ctx, owner, name, pr.Number)
|
||||
if err != nil {
|
||||
a.log(" Warning: failed to fetch reviews for PR #%d: %v", pr.Number, err)
|
||||
continue
|
||||
}
|
||||
reviews = append(reviews, prReviews...)
|
||||
}
|
||||
a.log(" Found %d reviews (REST)", len(reviews))
|
||||
|
||||
return prs, reviews, nil
|
||||
}
|
||||
|
||||
// fetchIssuesAndCommentsREST fetches issues and comments using the REST API (fallback when GraphQL fails)
|
||||
func (a *App) fetchIssuesAndCommentsREST(ctx context.Context, owner, name string, dateRange *config.ParsedDateRange, data *models.RawData) error {
|
||||
issues, err := a.client.FetchIssues(ctx, owner, name, dateRange.Start, dateRange.End)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch issues: %w", err)
|
||||
}
|
||||
a.log(" Found %d issues", len(issues))
|
||||
|
||||
// Filter out bots and add to data
|
||||
for _, issue := range issues {
|
||||
if !a.config.IsBot(issue.Author.Login) {
|
||||
data.Issues = append(data.Issues, issue)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all comments for the repository within date range
|
||||
comments, err := a.client.FetchIssueComments(ctx, owner, name, dateRange.Start, dateRange.End)
|
||||
if err != nil {
|
||||
a.log(" Warning: failed to fetch issue comments: %v", err)
|
||||
} else {
|
||||
for _, comment := range comments {
|
||||
if !a.config.IsBot(comment.Author.Login) {
|
||||
data.IssueComments = append(data.IssueComments, comment)
|
||||
}
|
||||
}
|
||||
a.log(" Found %d issue comments (REST)", len(comments))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+14
-10
@@ -152,6 +152,7 @@ type OptionsConfig struct {
|
||||
UseLocalGit bool `yaml:"use_local_git"` // Use local git for commits (faster)
|
||||
ShallowClone bool `yaml:"shallow_clone"` // Use shallow clone based on date range (faster cloning)
|
||||
ShallowCloneBuffer int `yaml:"shallow_clone_buffer"` // Extra commits to fetch beyond date range (default: 100)
|
||||
UseGraphQL bool `yaml:"use_graphql"` // Use GraphQL API for batched queries (fewer API calls)
|
||||
UserAliases []UserAlias `yaml:"user_aliases,omitempty"` // Manual email/name to login mappings
|
||||
}
|
||||
|
||||
@@ -159,16 +160,18 @@ type OptionsConfig struct {
|
||||
// These cannot be overridden by users to ensure consistent bot filtering
|
||||
func DefaultBotPatterns() []string {
|
||||
return []string{
|
||||
"*[bot]", // GitHub App bots: dependabot[bot], renovate[bot], etc.
|
||||
"dependabot*", // Dependabot variants
|
||||
"renovate*", // Renovate bot variants
|
||||
"github-actions*", // GitHub Actions
|
||||
"codecov*", // Codecov bot
|
||||
"snyk*", // Snyk security bot
|
||||
"greenkeeper*", // Greenkeeper (legacy)
|
||||
"imgbot*", // Image optimization bot
|
||||
"allcontributors*", // All Contributors bot
|
||||
"semantic-release*", // Semantic release bot
|
||||
"*[bot]", // GitHub App bots: dependabot[bot], renovate[bot], etc.
|
||||
"dependabot*", // Dependabot variants
|
||||
"renovate*", // Renovate bot variants
|
||||
"github-actions*", // GitHub Actions
|
||||
"github-advanced-security", // GitHub Advanced Security
|
||||
"*-actions-runner", // Self-hosted GitHub Actions runners
|
||||
"codecov*", // Codecov bot
|
||||
"snyk*", // Snyk security bot
|
||||
"greenkeeper*", // Greenkeeper (legacy)
|
||||
"imgbot*", // Image optimization bot
|
||||
"allcontributors*", // All Contributors bot
|
||||
"semantic-release*", // Semantic release bot
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +236,7 @@ func DefaultConfig() *Config {
|
||||
UseLocalGit: true, // Default to faster local git analysis
|
||||
ShallowClone: true, // Default to shallow clone for faster cloning
|
||||
ShallowCloneBuffer: 25, // Extra commits beyond date range for safety margin
|
||||
UseGraphQL: true, // Default to GraphQL for fewer API calls
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+109
-4
@@ -3,12 +3,15 @@ package git
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/progress"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/config"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
@@ -18,6 +21,55 @@ import (
|
||||
"github.com/lukaszraczylo/git-velocity/internal/domain/models"
|
||||
)
|
||||
|
||||
// commitProgressBar handles terminal progress display for commit iteration
|
||||
type commitProgressBar struct {
|
||||
progress progress.Model
|
||||
label string
|
||||
current int
|
||||
out io.Writer
|
||||
}
|
||||
|
||||
func newCommitProgressBar(label string) *commitProgressBar {
|
||||
p := progress.New(
|
||||
progress.WithDefaultGradient(),
|
||||
progress.WithWidth(40),
|
||||
)
|
||||
return &commitProgressBar{
|
||||
progress: p,
|
||||
label: label,
|
||||
current: 0,
|
||||
out: os.Stderr,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *commitProgressBar) update(count int) {
|
||||
p.current = count
|
||||
|
||||
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
|
||||
countStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||
|
||||
// Use a spinner-like display since we don't know total
|
||||
spinner := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
spinChar := spinner[count%len(spinner)]
|
||||
|
||||
fmt.Fprintf(p.out, "\r%s %s %s",
|
||||
labelStyle.Render(p.label),
|
||||
spinChar,
|
||||
countStyle.Render(fmt.Sprintf("%d commits", p.current)),
|
||||
)
|
||||
}
|
||||
|
||||
func (p *commitProgressBar) done(total int) {
|
||||
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
|
||||
countStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||
|
||||
fmt.Fprintf(p.out, "\r%s %s %s\n",
|
||||
labelStyle.Render(p.label),
|
||||
p.progress.ViewAs(1.0),
|
||||
countStyle.Render(fmt.Sprintf("%d commits", total)),
|
||||
)
|
||||
}
|
||||
|
||||
// ProgressCallback is called to report progress during git operations
|
||||
type ProgressCallback func(message string)
|
||||
|
||||
@@ -158,8 +210,6 @@ func (r *Repository) FetchCommits(ctx context.Context, owner, name string, since
|
||||
return nil, fmt.Errorf("failed to open repository: %w", err)
|
||||
}
|
||||
|
||||
r.progress(" Iterating commits with go-git...")
|
||||
|
||||
// Get all references to iterate all branches
|
||||
refs, err := repo.References()
|
||||
if err != nil {
|
||||
@@ -171,6 +221,20 @@ func (r *Repository) FetchCommits(ctx context.Context, owner, name string, since
|
||||
var commits []models.Commit
|
||||
testPatterns := []string{"_test.go", ".test.", ".spec.", "/tests/", "/test/", "__tests__"}
|
||||
|
||||
// Progress bar for commit iteration
|
||||
pbar := newCommitProgressBar(" Iterating commits:")
|
||||
processedCount := 0
|
||||
|
||||
// Hard cutoff: 1 week before start date - stop iterating entirely past this point
|
||||
var hardCutoff *time.Time
|
||||
if since != nil {
|
||||
cutoff := since.AddDate(0, 0, -7)
|
||||
hardCutoff = &cutoff
|
||||
}
|
||||
|
||||
// errStopIteration is used to signal early termination (not a real error)
|
||||
var errStopIteration = fmt.Errorf("stop iteration")
|
||||
|
||||
err = refs.ForEach(func(ref *plumbing.Reference) error {
|
||||
// Skip non-branch references
|
||||
if !ref.Name().IsBranch() && !ref.Name().IsRemote() && !ref.Name().IsTag() {
|
||||
@@ -188,6 +252,7 @@ func (r *Repository) FetchCommits(ctx context.Context, owner, name string, since
|
||||
return nil
|
||||
}
|
||||
|
||||
consecutiveOld := 0
|
||||
err = commitIter.ForEach(func(c *object.Commit) error {
|
||||
// Check context cancellation
|
||||
select {
|
||||
@@ -201,13 +266,31 @@ func (r *Repository) FetchCommits(ctx context.Context, owner, name string, since
|
||||
return nil
|
||||
}
|
||||
seenCommits[c.Hash] = true
|
||||
processedCount++
|
||||
|
||||
// Update progress every 10 commits to avoid too much I/O
|
||||
if processedCount%10 == 0 {
|
||||
pbar.update(processedCount)
|
||||
}
|
||||
|
||||
commitTime := c.Author.When
|
||||
|
||||
// Hard cutoff - stop entirely if past this date
|
||||
if hardCutoff != nil && commitTime.Before(*hardCutoff) {
|
||||
return errStopIteration
|
||||
}
|
||||
|
||||
// Filter by date range
|
||||
if since != nil && commitTime.Before(*since) {
|
||||
consecutiveOld++
|
||||
// Early termination: if we've seen 100 consecutive old commits, stop this branch
|
||||
if consecutiveOld >= 100 {
|
||||
return errStopIteration
|
||||
}
|
||||
return nil
|
||||
}
|
||||
consecutiveOld = 0 // Reset counter when we find a valid commit
|
||||
|
||||
if until != nil && commitTime.After(*until) {
|
||||
return nil
|
||||
}
|
||||
@@ -249,15 +332,27 @@ func (r *Repository) FetchCommits(ctx context.Context, owner, name string, since
|
||||
return nil
|
||||
})
|
||||
|
||||
// Handle expected termination conditions
|
||||
if err == errStopIteration {
|
||||
return nil // Not an error, just early termination for this branch
|
||||
}
|
||||
|
||||
// Handle shallow clone boundary - "object not found" means we've reached
|
||||
// the edge of the shallow clone history, which is expected behavior
|
||||
if err != nil && isShallowBoundaryError(err) {
|
||||
err = nil // Treat as normal end of history
|
||||
}
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
// Complete progress bar
|
||||
pbar.done(len(commits))
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to iterate commits: %w", err)
|
||||
}
|
||||
|
||||
r.progress(fmt.Sprintf(" Found %d commits", len(commits)))
|
||||
|
||||
return commits, nil
|
||||
}
|
||||
|
||||
@@ -372,6 +467,16 @@ func (r *Repository) getCommitStats(c *object.Commit, testPatterns []string) com
|
||||
return stats
|
||||
}
|
||||
|
||||
// isShallowBoundaryError checks if an error indicates we've hit the shallow clone boundary
|
||||
func isShallowBoundaryError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
errStr := err.Error()
|
||||
// go-git returns "object not found" when trying to access commits beyond shallow depth
|
||||
return strings.Contains(errStr, "object not found")
|
||||
}
|
||||
|
||||
// extractLoginFromEmail tries to extract GitHub login from email
|
||||
func extractLoginFromEmail(email, fallbackName string) string {
|
||||
// Pattern: 12345678+username@users.noreply.github.com
|
||||
|
||||
@@ -41,6 +41,7 @@ func DefaultRetryConfig() RetryConfig {
|
||||
// Client wraps the GitHub API client with rate limiting and caching
|
||||
type Client struct {
|
||||
gh *github.Client
|
||||
gql *GraphQLClient // GraphQL client for batched queries
|
||||
config *config.Config
|
||||
cache cache.Cache
|
||||
retry RetryConfig
|
||||
@@ -91,8 +92,15 @@ func NewClient(ctx context.Context, cfg *config.Config) (*Client, error) {
|
||||
c = cache.NewNoopCache()
|
||||
}
|
||||
|
||||
// Initialize GraphQL client if using token auth (GraphQL doesn't support GitHub App auth easily)
|
||||
var gql *GraphQLClient
|
||||
if cfg.HasGithubToken() && cfg.Options.UseGraphQL {
|
||||
gql = NewGraphQLClient(cfg.Auth.GithubToken)
|
||||
}
|
||||
|
||||
return &Client{
|
||||
gh: gh,
|
||||
gql: gql,
|
||||
config: cfg,
|
||||
cache: c,
|
||||
retry: DefaultRetryConfig(),
|
||||
@@ -107,6 +115,73 @@ func (c *Client) SetProgressCallback(cb ProgressCallback) {
|
||||
}
|
||||
}
|
||||
|
||||
// HasGraphQL returns true if the GraphQL client is available
|
||||
func (c *Client) HasGraphQL() bool {
|
||||
return c.gql != nil
|
||||
}
|
||||
|
||||
// FetchPRsWithReviewsGraphQL fetches PRs and reviews using GraphQL (much fewer API calls)
|
||||
func (c *Client) FetchPRsWithReviewsGraphQL(ctx context.Context, owner, repo string, since, until *time.Time) ([]models.PullRequest, []models.Review, error) {
|
||||
if c.gql == nil {
|
||||
return nil, nil, fmt.Errorf("GraphQL client not initialized")
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("gql_prs_reviews:%s/%s:%v:%v", owner, repo, since, until)
|
||||
|
||||
// Check cache
|
||||
type cachedData struct {
|
||||
PRs []models.PullRequest
|
||||
Reviews []models.Review
|
||||
}
|
||||
if cached, ok := c.cache.Get(cacheKey); ok {
|
||||
if data, ok := cached.(cachedData); ok {
|
||||
c.progress(" Using cached PRs and reviews data (GraphQL)")
|
||||
return data.PRs, data.Reviews, nil
|
||||
}
|
||||
}
|
||||
|
||||
prs, reviews, err := c.gql.FetchPRsWithReviews(ctx, owner, repo, since, until)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Cache results
|
||||
c.cache.Set(cacheKey, cachedData{PRs: prs, Reviews: reviews})
|
||||
|
||||
return prs, reviews, nil
|
||||
}
|
||||
|
||||
// FetchIssuesWithCommentsGraphQL fetches issues and comments using GraphQL (much fewer API calls)
|
||||
func (c *Client) FetchIssuesWithCommentsGraphQL(ctx context.Context, owner, repo string, since, until *time.Time) ([]models.Issue, []models.IssueComment, error) {
|
||||
if c.gql == nil {
|
||||
return nil, nil, fmt.Errorf("GraphQL client not initialized")
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("gql_issues_comments:%s/%s:%v:%v", owner, repo, since, until)
|
||||
|
||||
// Check cache
|
||||
type cachedData struct {
|
||||
Issues []models.Issue
|
||||
Comments []models.IssueComment
|
||||
}
|
||||
if cached, ok := c.cache.Get(cacheKey); ok {
|
||||
if data, ok := cached.(cachedData); ok {
|
||||
c.progress(" Using cached issues and comments data (GraphQL)")
|
||||
return data.Issues, data.Comments, nil
|
||||
}
|
||||
}
|
||||
|
||||
issues, comments, err := c.gql.FetchIssuesWithComments(ctx, owner, repo, since, until)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Cache results
|
||||
c.cache.Set(cacheKey, cachedData{Issues: issues, Comments: comments})
|
||||
|
||||
return issues, comments, nil
|
||||
}
|
||||
|
||||
// SetRetryConfig sets the retry configuration
|
||||
func (c *Client) SetRetryConfig(rc RetryConfig) {
|
||||
c.retry = rc
|
||||
@@ -459,6 +534,7 @@ func (c *Client) fetchPRsForBranch(ctx context.Context, owner, repo, baseBranch
|
||||
ResourceName: "pull requests",
|
||||
EarlyTermination: true,
|
||||
EarlyTerminationThreshold: 2,
|
||||
Quiet: true, // Parent function handles progress
|
||||
}
|
||||
|
||||
return FetchAllPages(ctx, c, "", config, fetcher) // Empty cache key - parent handles caching
|
||||
@@ -489,6 +565,7 @@ func (c *Client) FetchReviews(ctx context.Context, owner, repo string, prNumber
|
||||
|
||||
config := DefaultFetchConfig("reviews")
|
||||
config.EarlyTermination = false // Reviews don't need date-based early termination
|
||||
config.Quiet = true // Suppress per-page progress (called many times in parallel)
|
||||
|
||||
return FetchAllPages(ctx, c, cacheKey, config, fetcher)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ type FetchConfig struct {
|
||||
EarlyTermination bool
|
||||
// EarlyTerminationThreshold is the number of consecutive old pages before stopping
|
||||
EarlyTerminationThreshold int
|
||||
// Quiet suppresses per-page progress messages (useful for sub-fetches like reviews)
|
||||
Quiet bool
|
||||
}
|
||||
|
||||
// DefaultFetchConfig returns sensible defaults
|
||||
@@ -91,8 +93,15 @@ func FetchAllPages[T any, R any](
|
||||
return nil, fmt.Errorf("failed to fetch %s: %w", config.ResourceName, err)
|
||||
}
|
||||
|
||||
c.progress(fmt.Sprintf(" Fetching %s page %d (%d %s so far)...",
|
||||
config.ResourceName, page, len(allResults), config.ResourceName))
|
||||
// Safety check for nil response
|
||||
if resp == nil {
|
||||
break
|
||||
}
|
||||
|
||||
if !config.Quiet {
|
||||
c.progress(fmt.Sprintf(" Fetching %s page %d (%d %s so far)...",
|
||||
config.ResourceName, page, len(allResults), config.ResourceName))
|
||||
}
|
||||
|
||||
oldInPage := 0
|
||||
totalEligible := 0
|
||||
@@ -121,8 +130,10 @@ func FetchAllPages[T any, R any](
|
||||
if config.EarlyTermination && totalEligible > 0 && oldInPage == totalEligible {
|
||||
consecutiveOldPages++
|
||||
if consecutiveOldPages >= config.EarlyTerminationThreshold {
|
||||
c.progress(fmt.Sprintf(" Reached %s older than date range, stopping early (page %d)",
|
||||
config.ResourceName, page))
|
||||
if !config.Quiet {
|
||||
c.progress(fmt.Sprintf(" Reached %s older than date range, stopping early (page %d)",
|
||||
config.ResourceName, page))
|
||||
}
|
||||
break
|
||||
}
|
||||
} else {
|
||||
@@ -260,8 +271,15 @@ func FetchAllPagesWithEnrichment[T any, R any](
|
||||
return nil, fmt.Errorf("failed to fetch %s: %w", config.ResourceName, err)
|
||||
}
|
||||
|
||||
c.progress(fmt.Sprintf(" Fetching %s page %d (%d %s so far)...",
|
||||
config.ResourceName, page, len(allResults), config.ResourceName))
|
||||
// Safety check for nil response
|
||||
if resp == nil {
|
||||
break
|
||||
}
|
||||
|
||||
if !config.Quiet {
|
||||
c.progress(fmt.Sprintf(" Fetching %s page %d (%d %s so far)...",
|
||||
config.ResourceName, page, len(allResults), config.ResourceName))
|
||||
}
|
||||
|
||||
itemsInPage := 0
|
||||
for i, item := range items {
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/progress"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/lukaszraczylo/git-velocity/internal/domain/models"
|
||||
"github.com/shurcooL/githubv4"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// progressBar handles terminal progress display
|
||||
type progressBar struct {
|
||||
progress progress.Model
|
||||
label string
|
||||
total int
|
||||
current int
|
||||
out io.Writer
|
||||
}
|
||||
|
||||
func newProgressBar(label string, total int) *progressBar {
|
||||
p := progress.New(
|
||||
progress.WithDefaultGradient(),
|
||||
progress.WithWidth(40),
|
||||
)
|
||||
return &progressBar{
|
||||
progress: p,
|
||||
label: label,
|
||||
total: total,
|
||||
current: 0,
|
||||
out: os.Stderr,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *progressBar) update(fetched int) {
|
||||
p.current = fetched
|
||||
percent := float64(p.current) / float64(p.total)
|
||||
if percent > 1.0 {
|
||||
percent = 1.0
|
||||
}
|
||||
|
||||
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
|
||||
countStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||
|
||||
fmt.Fprintf(p.out, "\r%s %s %s",
|
||||
labelStyle.Render(p.label),
|
||||
p.progress.ViewAs(percent),
|
||||
countStyle.Render(fmt.Sprintf("%d/%d", p.current, p.total)),
|
||||
)
|
||||
}
|
||||
|
||||
func (p *progressBar) done() {
|
||||
p.update(p.total)
|
||||
fmt.Fprintln(p.out)
|
||||
}
|
||||
|
||||
// GraphQLClient wraps the githubv4 client for GitHub API
|
||||
type GraphQLClient struct {
|
||||
client *githubv4.Client
|
||||
}
|
||||
|
||||
// NewGraphQLClient creates a new GraphQL client for GitHub
|
||||
func NewGraphQLClient(token string) *GraphQLClient {
|
||||
src := oauth2.StaticTokenSource(
|
||||
&oauth2.Token{AccessToken: token},
|
||||
)
|
||||
httpClient := oauth2.NewClient(context.Background(), src)
|
||||
client := githubv4.NewClient(httpClient)
|
||||
|
||||
return &GraphQLClient{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// PageInfo contains pagination info from GraphQL responses
|
||||
type PageInfo struct {
|
||||
HasNextPage bool
|
||||
EndCursor githubv4.String
|
||||
}
|
||||
|
||||
// PageResult represents a page of results from GraphQL
|
||||
type PageResult[T any] struct {
|
||||
TotalCount int
|
||||
PageInfo PageInfo
|
||||
Nodes []T
|
||||
}
|
||||
|
||||
// GQLFetchConfig configures the generic paginated fetcher for GraphQL
|
||||
type GQLFetchConfig[Q any, T any, R any] struct {
|
||||
Label string
|
||||
Query *Q
|
||||
GetPageResult func(q *Q) PageResult[T]
|
||||
// ProcessNode returns items, whether this node is "old" (outside date range),
|
||||
// and whether to hard stop immediately (past cutoff date)
|
||||
ProcessNode func(node T, repo string) (items []R, isOld bool, hardStop bool)
|
||||
// ConsecutiveOldPagesToStop controls early termination (default: 2)
|
||||
ConsecutiveOldPagesToStop int
|
||||
}
|
||||
|
||||
// fetchGQLPaginated is a generic paginated fetcher for GraphQL queries
|
||||
func fetchGQLPaginated[Q any, T any, R any](
|
||||
ctx context.Context,
|
||||
client *githubv4.Client,
|
||||
owner, repo string,
|
||||
config GQLFetchConfig[Q, T, R],
|
||||
) ([]R, error) {
|
||||
var allResults []R
|
||||
|
||||
variables := map[string]interface{}{
|
||||
"owner": githubv4.String(owner),
|
||||
"repo": githubv4.String(repo),
|
||||
"cursor": (*githubv4.String)(nil),
|
||||
}
|
||||
|
||||
var pbar *progressBar
|
||||
fetched := 0
|
||||
repoFullName := fmt.Sprintf("%s/%s", owner, repo)
|
||||
consecutiveOldPages := 0
|
||||
pagesToStop := config.ConsecutiveOldPagesToStop
|
||||
if pagesToStop == 0 {
|
||||
pagesToStop = 2 // default
|
||||
}
|
||||
|
||||
for {
|
||||
if err := client.Query(ctx, config.Query, variables); err != nil {
|
||||
return nil, fmt.Errorf("graphql query failed: %w", err)
|
||||
}
|
||||
|
||||
page := config.GetPageResult(config.Query)
|
||||
|
||||
// Initialize progress bar on first query
|
||||
if pbar == nil && page.TotalCount > 0 {
|
||||
pbar = newProgressBar(config.Label, page.TotalCount)
|
||||
}
|
||||
|
||||
oldInPage := 0
|
||||
totalInPage := 0
|
||||
shouldHardStop := false
|
||||
for _, node := range page.Nodes {
|
||||
fetched++
|
||||
totalInPage++
|
||||
items, isOld, hardStop := config.ProcessNode(node, repoFullName)
|
||||
allResults = append(allResults, items...)
|
||||
if isOld {
|
||||
oldInPage++
|
||||
}
|
||||
if hardStop {
|
||||
shouldHardStop = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if pbar != nil {
|
||||
pbar.update(fetched)
|
||||
}
|
||||
|
||||
// Hard stop takes priority (past cutoff date)
|
||||
if shouldHardStop {
|
||||
if pbar != nil {
|
||||
pbar.done()
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Track consecutive pages where all items are old
|
||||
if totalInPage > 0 && oldInPage == totalInPage {
|
||||
consecutiveOldPages++
|
||||
} else {
|
||||
consecutiveOldPages = 0
|
||||
}
|
||||
|
||||
// Stop if we've seen enough consecutive old pages or no more pages
|
||||
if consecutiveOldPages >= pagesToStop || !page.PageInfo.HasNextPage {
|
||||
if pbar != nil {
|
||||
pbar.done()
|
||||
}
|
||||
break
|
||||
}
|
||||
variables["cursor"] = githubv4.NewString(page.PageInfo.EndCursor)
|
||||
}
|
||||
|
||||
return allResults, nil
|
||||
}
|
||||
|
||||
// Query structs for PRs with reviews
|
||||
type gqlPRQuery struct {
|
||||
Repository struct {
|
||||
PullRequests struct {
|
||||
TotalCount int
|
||||
PageInfo PageInfo
|
||||
Nodes []gqlPRNode
|
||||
} `graphql:"pullRequests(first: 100, after: $cursor, states: [MERGED], orderBy: {field: UPDATED_AT, direction: DESC})"`
|
||||
} `graphql:"repository(owner: $owner, name: $repo)"`
|
||||
}
|
||||
|
||||
type gqlPRNode struct {
|
||||
Number int
|
||||
Title string
|
||||
State string
|
||||
Merged bool
|
||||
Additions int
|
||||
Deletions int
|
||||
ChangedFiles int
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
MergedAt *time.Time
|
||||
ClosedAt *time.Time
|
||||
BaseRefName string
|
||||
HeadRefName string
|
||||
URL string
|
||||
Commits struct{ TotalCount int }
|
||||
Author gqlActor
|
||||
Reviews struct {
|
||||
TotalCount int
|
||||
Nodes []gqlReviewNode
|
||||
PageInfo PageInfo
|
||||
} `graphql:"reviews(first: 100)"`
|
||||
}
|
||||
|
||||
type gqlActor struct {
|
||||
Login string
|
||||
AvatarURL string `graphql:"avatarUrl"`
|
||||
}
|
||||
|
||||
type gqlReviewNode struct {
|
||||
ID string `graphql:"id"`
|
||||
Author gqlActor
|
||||
State string
|
||||
SubmittedAt *time.Time
|
||||
Body string
|
||||
Comments struct{ TotalCount int } `graphql:"comments"`
|
||||
}
|
||||
|
||||
// Query struct for issues with comments
|
||||
type gqlIssueQuery struct {
|
||||
Repository struct {
|
||||
Issues struct {
|
||||
TotalCount int
|
||||
PageInfo PageInfo
|
||||
Nodes []gqlIssueNode
|
||||
} `graphql:"issues(first: 100, after: $cursor, orderBy: {field: CREATED_AT, direction: DESC})"`
|
||||
} `graphql:"repository(owner: $owner, name: $repo)"`
|
||||
}
|
||||
|
||||
type gqlIssueNode struct {
|
||||
Number int
|
||||
Title string
|
||||
State string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ClosedAt *time.Time
|
||||
URL string
|
||||
Author gqlActor
|
||||
Labels struct {
|
||||
Nodes []struct{ Name string }
|
||||
} `graphql:"labels(first: 10)"`
|
||||
Comments struct {
|
||||
TotalCount int
|
||||
Nodes []gqlCommentNode
|
||||
PageInfo PageInfo
|
||||
} `graphql:"comments(first: 100)"`
|
||||
}
|
||||
|
||||
type gqlCommentNode struct {
|
||||
ID string `graphql:"id"`
|
||||
Author gqlActor
|
||||
Body string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// prWithReviews bundles a PR with its reviews for the generic fetcher
|
||||
type prWithReviews struct {
|
||||
PR models.PullRequest
|
||||
Reviews []models.Review
|
||||
}
|
||||
|
||||
// FetchPRsWithReviews fetches pull requests with their reviews using GraphQL
|
||||
func (g *GraphQLClient) FetchPRsWithReviews(ctx context.Context, owner, repo string, since, until *time.Time) ([]models.PullRequest, []models.Review, error) {
|
||||
var query gqlPRQuery
|
||||
|
||||
// Hard cutoff: 1 week before start date - stop fetching entirely past this point
|
||||
var hardCutoff *time.Time
|
||||
if since != nil {
|
||||
cutoff := since.AddDate(0, 0, -7)
|
||||
hardCutoff = &cutoff
|
||||
}
|
||||
|
||||
results, err := fetchGQLPaginated(ctx, g.client, owner, repo, GQLFetchConfig[gqlPRQuery, gqlPRNode, prWithReviews]{
|
||||
Label: " Fetching PRs:",
|
||||
Query: &query,
|
||||
ConsecutiveOldPagesToStop: 2,
|
||||
GetPageResult: func(q *gqlPRQuery) PageResult[gqlPRNode] {
|
||||
return PageResult[gqlPRNode]{
|
||||
TotalCount: q.Repository.PullRequests.TotalCount,
|
||||
PageInfo: q.Repository.PullRequests.PageInfo,
|
||||
Nodes: q.Repository.PullRequests.Nodes,
|
||||
}
|
||||
},
|
||||
ProcessNode: func(node gqlPRNode, repoName string) ([]prWithReviews, bool, bool) {
|
||||
// Skip if not merged - not counted as "old"
|
||||
if node.MergedAt == nil {
|
||||
return nil, false, false
|
||||
}
|
||||
|
||||
mergedAt := *node.MergedAt
|
||||
|
||||
// Hard cutoff check - stop entirely if past this date
|
||||
if hardCutoff != nil && mergedAt.Before(*hardCutoff) {
|
||||
return nil, true, true // Hard stop
|
||||
}
|
||||
|
||||
// Check date range - skip if outside range
|
||||
if until != nil && mergedAt.After(*until) {
|
||||
return nil, false, false // Too new, not "old"
|
||||
}
|
||||
if since != nil && mergedAt.Before(*since) {
|
||||
return nil, true, false // Too old - signal for early termination tracking
|
||||
}
|
||||
|
||||
// Convert PR
|
||||
pr := convertPRNode(node, repoName)
|
||||
|
||||
// Convert reviews
|
||||
var reviews []models.Review
|
||||
for _, r := range node.Reviews.Nodes {
|
||||
reviews = append(reviews, convertReviewNode(r, repoName, node.Number))
|
||||
}
|
||||
|
||||
return []prWithReviews{{PR: pr, Reviews: reviews}}, false, false
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Flatten results
|
||||
var prs []models.PullRequest
|
||||
var reviews []models.Review
|
||||
for _, r := range results {
|
||||
prs = append(prs, r.PR)
|
||||
reviews = append(reviews, r.Reviews...)
|
||||
}
|
||||
|
||||
return prs, reviews, nil
|
||||
}
|
||||
|
||||
// issueWithComments bundles an issue with its comments for the generic fetcher
|
||||
type issueWithComments struct {
|
||||
Issue models.Issue
|
||||
Comments []models.IssueComment
|
||||
}
|
||||
|
||||
// FetchIssuesWithComments fetches issues with their comments using GraphQL
|
||||
func (g *GraphQLClient) FetchIssuesWithComments(ctx context.Context, owner, repo string, since, until *time.Time) ([]models.Issue, []models.IssueComment, error) {
|
||||
var query gqlIssueQuery
|
||||
|
||||
// Hard cutoff: 1 week before start date - stop fetching entirely past this point
|
||||
var hardCutoff *time.Time
|
||||
if since != nil {
|
||||
cutoff := since.AddDate(0, 0, -7)
|
||||
hardCutoff = &cutoff
|
||||
}
|
||||
|
||||
results, err := fetchGQLPaginated(ctx, g.client, owner, repo, GQLFetchConfig[gqlIssueQuery, gqlIssueNode, issueWithComments]{
|
||||
Label: " Fetching issues:",
|
||||
Query: &query,
|
||||
ConsecutiveOldPagesToStop: 2,
|
||||
GetPageResult: func(q *gqlIssueQuery) PageResult[gqlIssueNode] {
|
||||
return PageResult[gqlIssueNode]{
|
||||
TotalCount: q.Repository.Issues.TotalCount,
|
||||
PageInfo: q.Repository.Issues.PageInfo,
|
||||
Nodes: q.Repository.Issues.Nodes,
|
||||
}
|
||||
},
|
||||
ProcessNode: func(node gqlIssueNode, repoName string) ([]issueWithComments, bool, bool) {
|
||||
// Hard cutoff check - stop entirely if past this date
|
||||
if hardCutoff != nil && node.CreatedAt.Before(*hardCutoff) {
|
||||
return nil, true, true // Hard stop
|
||||
}
|
||||
|
||||
// Check date range
|
||||
if until != nil && node.CreatedAt.After(*until) {
|
||||
return nil, false, false // Too new, not "old"
|
||||
}
|
||||
if since != nil && node.CreatedAt.Before(*since) {
|
||||
return nil, true, false // Too old - signal for early termination tracking
|
||||
}
|
||||
|
||||
// Convert issue
|
||||
issue := convertIssueNode(node, repoName)
|
||||
|
||||
// Convert comments within date range
|
||||
var comments []models.IssueComment
|
||||
for _, c := range node.Comments.Nodes {
|
||||
if until != nil && c.CreatedAt.After(*until) {
|
||||
continue
|
||||
}
|
||||
if since != nil && c.CreatedAt.Before(*since) {
|
||||
continue
|
||||
}
|
||||
comments = append(comments, convertCommentNode(c, repoName, node.Number))
|
||||
}
|
||||
|
||||
return []issueWithComments{{Issue: issue, Comments: comments}}, false, false
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Flatten results
|
||||
var issues []models.Issue
|
||||
var comments []models.IssueComment
|
||||
for _, r := range results {
|
||||
issues = append(issues, r.Issue)
|
||||
comments = append(comments, r.Comments...)
|
||||
}
|
||||
|
||||
return issues, comments, nil
|
||||
}
|
||||
|
||||
// Conversion helpers
|
||||
|
||||
func convertActor(a gqlActor) models.Author {
|
||||
return models.Author{
|
||||
Login: a.Login,
|
||||
AvatarURL: a.AvatarURL,
|
||||
}
|
||||
}
|
||||
|
||||
func convertPRNode(node gqlPRNode, repoName string) models.PullRequest {
|
||||
state := models.PRStateOpen
|
||||
if node.Merged {
|
||||
state = models.PRStateMerged
|
||||
} else if node.State == "CLOSED" {
|
||||
state = models.PRStateClosed
|
||||
}
|
||||
|
||||
return models.PullRequest{
|
||||
Number: node.Number,
|
||||
Title: node.Title,
|
||||
State: state,
|
||||
Author: convertActor(node.Author),
|
||||
Repository: repoName,
|
||||
BaseBranch: node.BaseRefName,
|
||||
HeadBranch: node.HeadRefName,
|
||||
CreatedAt: node.CreatedAt,
|
||||
UpdatedAt: node.UpdatedAt,
|
||||
MergedAt: node.MergedAt,
|
||||
ClosedAt: node.ClosedAt,
|
||||
Additions: node.Additions,
|
||||
Deletions: node.Deletions,
|
||||
FilesChanged: node.ChangedFiles,
|
||||
CommitCount: node.Commits.TotalCount,
|
||||
Comments: node.Reviews.TotalCount,
|
||||
URL: node.URL,
|
||||
}
|
||||
}
|
||||
|
||||
func convertReviewNode(node gqlReviewNode, repoName string, prNumber int) models.Review {
|
||||
var submittedAt time.Time
|
||||
if node.SubmittedAt != nil {
|
||||
submittedAt = *node.SubmittedAt
|
||||
}
|
||||
|
||||
return models.Review{
|
||||
PullRequest: prNumber,
|
||||
Repository: repoName,
|
||||
Author: convertActor(node.Author),
|
||||
State: models.ReviewState(node.State),
|
||||
SubmittedAt: submittedAt,
|
||||
Body: node.Body,
|
||||
CommentsCount: node.Comments.TotalCount,
|
||||
}
|
||||
}
|
||||
|
||||
func convertIssueNode(node gqlIssueNode, repoName string) models.Issue {
|
||||
state := models.IssueStateOpen
|
||||
if node.State == "CLOSED" {
|
||||
state = models.IssueStateClosed
|
||||
}
|
||||
|
||||
var labels []string
|
||||
for _, l := range node.Labels.Nodes {
|
||||
labels = append(labels, l.Name)
|
||||
}
|
||||
|
||||
return models.Issue{
|
||||
Number: node.Number,
|
||||
Title: node.Title,
|
||||
State: state,
|
||||
Author: convertActor(node.Author),
|
||||
Repository: repoName,
|
||||
CreatedAt: node.CreatedAt,
|
||||
UpdatedAt: node.UpdatedAt,
|
||||
ClosedAt: node.ClosedAt,
|
||||
Comments: node.Comments.TotalCount,
|
||||
Labels: labels,
|
||||
URL: node.URL,
|
||||
}
|
||||
}
|
||||
|
||||
func convertCommentNode(node gqlCommentNode, repoName string, issueNumber int) models.IssueComment {
|
||||
return models.IssueComment{
|
||||
Issue: issueNumber,
|
||||
Repository: repoName,
|
||||
Author: convertActor(node.Author),
|
||||
Body: node.Body,
|
||||
CreatedAt: node.CreatedAt,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user