mirror of
https://github.com/go-i2p/go-github-dashboard.git
synced 2025-06-07 10:01:44 -04:00
basic idea...
This commit is contained in:
7
cmd/github-dashboard/main.go
Normal file
7
cmd/github-dashboard/main.go
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "github.com/go-i2p/go-github-dashboard/pkg/cmd"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cmd.Execute()
|
||||||
|
}
|
40
go.mod
Normal file
40
go.mod
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
module github.com/go-i2p/go-github-dashboard
|
||||||
|
|
||||||
|
go 1.24.2
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/google/go-github/v58 v58.0.0
|
||||||
|
github.com/hashicorp/go-retryablehttp v0.7.7
|
||||||
|
github.com/mmcdole/gofeed v1.3.0
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0
|
||||||
|
github.com/spf13/cobra v1.9.1
|
||||||
|
github.com/spf13/viper v1.20.1
|
||||||
|
golang.org/x/oauth2 v0.30.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/PuerkitoBio/goquery v1.8.0 // indirect
|
||||||
|
github.com/andybalholm/cascadia v1.3.1 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.8.0 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||||
|
github.com/google/go-querystring v1.1.0 // indirect
|
||||||
|
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // 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.3 // indirect
|
||||||
|
github.com/sagikazarmark/locafero v0.7.0 // indirect
|
||||||
|
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||||
|
github.com/spf13/afero v1.12.0 // indirect
|
||||||
|
github.com/spf13/cast v1.7.1 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
go.uber.org/atomic v1.9.0 // indirect
|
||||||
|
go.uber.org/multierr v1.9.0 // indirect
|
||||||
|
golang.org/x/net v0.33.0 // indirect
|
||||||
|
golang.org/x/sys v0.29.0 // indirect
|
||||||
|
golang.org/x/text v0.21.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
103
go.sum
Normal file
103
go.sum
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
|
||||||
|
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
|
||||||
|
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
|
||||||
|
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
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/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||||
|
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||||
|
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/go-github/v58 v58.0.0 h1:Una7GGERlF/37XfkPwpzYJe0Vp4dt2k1kCjlxwjIvzw=
|
||||||
|
github.com/google/go-github/v58 v58.0.0/go.mod h1:k4hxDKEfoWpSqFlc8LTpGd9fu2KrV1YAa6Hi6FmDNY4=
|
||||||
|
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||||
|
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||||
|
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||||
|
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||||
|
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||||
|
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
|
||||||
|
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
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/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
|
||||||
|
github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
|
||||||
|
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk=
|
||||||
|
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||||
|
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/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
|
||||||
|
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
|
||||||
|
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||||
|
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||||
|
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
|
||||||
|
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
|
||||||
|
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||||
|
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||||
|
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||||
|
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||||
|
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
||||||
|
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||||
|
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||||
|
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||||
|
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
||||||
|
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||||
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
|
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||||
|
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||||
|
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
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.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||||
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
137
pkg/api/cache.go
Normal file
137
pkg/api/cache.go
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/gob"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-i2p/go-github-dashboard/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CacheItem represents a cached item with its expiration time
|
||||||
|
type CacheItem struct {
|
||||||
|
Value interface{}
|
||||||
|
Expiration time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache provides a simple caching mechanism for API responses
|
||||||
|
type Cache struct {
|
||||||
|
items map[string]CacheItem
|
||||||
|
mutex sync.RWMutex
|
||||||
|
dir string
|
||||||
|
ttl time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCache creates a new cache with the given directory and TTL
|
||||||
|
func NewCache(config *types.Config) *Cache {
|
||||||
|
// Register types for gob encoding
|
||||||
|
gob.Register([]types.Repository{})
|
||||||
|
gob.Register([]types.PullRequest{})
|
||||||
|
gob.Register([]types.Issue{})
|
||||||
|
gob.Register([]types.Discussion{})
|
||||||
|
|
||||||
|
cache := &Cache{
|
||||||
|
items: make(map[string]CacheItem),
|
||||||
|
dir: config.CacheDir,
|
||||||
|
ttl: config.CacheTTL,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load cache from disk
|
||||||
|
cache.loadCache()
|
||||||
|
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a value from the cache
|
||||||
|
func (c *Cache) Get(key string) (interface{}, bool) {
|
||||||
|
c.mutex.RLock()
|
||||||
|
defer c.mutex.RUnlock()
|
||||||
|
|
||||||
|
item, found := c.items[key]
|
||||||
|
if !found {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the item has expired
|
||||||
|
if time.Now().After(item.Expiration) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return item.Value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set stores a value in the cache
|
||||||
|
func (c *Cache) Set(key string, value interface{}) {
|
||||||
|
c.mutex.Lock()
|
||||||
|
defer c.mutex.Unlock()
|
||||||
|
|
||||||
|
c.items[key] = CacheItem{
|
||||||
|
Value: value,
|
||||||
|
Expiration: time.Now().Add(c.ttl),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the cache to disk (in a separate goroutine to avoid blocking)
|
||||||
|
go c.saveCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear clears all items from the cache
|
||||||
|
func (c *Cache) Clear() {
|
||||||
|
c.mutex.Lock()
|
||||||
|
defer c.mutex.Unlock()
|
||||||
|
|
||||||
|
c.items = make(map[string]CacheItem)
|
||||||
|
go c.saveCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveCache saves the cache to disk
|
||||||
|
func (c *Cache) saveCache() {
|
||||||
|
c.mutex.RLock()
|
||||||
|
defer c.mutex.RUnlock()
|
||||||
|
|
||||||
|
cacheFile := filepath.Join(c.dir, "cache.gob")
|
||||||
|
file, err := os.Create(cacheFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error creating cache file: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
encoder := gob.NewEncoder(file)
|
||||||
|
err = encoder.Encode(c.items)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error encoding cache: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadCache loads the cache from disk
|
||||||
|
func (c *Cache) loadCache() {
|
||||||
|
cacheFile := filepath.Join(c.dir, "cache.gob")
|
||||||
|
file, err := os.Open(cacheFile)
|
||||||
|
if err != nil {
|
||||||
|
// If the file doesn't exist, that's not an error
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
fmt.Printf("Error opening cache file: %v\n", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
decoder := gob.NewDecoder(file)
|
||||||
|
err = decoder.Decode(&c.items)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error decoding cache: %v\n", err)
|
||||||
|
// If there's an error decoding, start with a fresh cache
|
||||||
|
c.items = make(map[string]CacheItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove expired items
|
||||||
|
now := time.Now()
|
||||||
|
for key, item := range c.items {
|
||||||
|
if now.After(item.Expiration) {
|
||||||
|
delete(c.items, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
296
pkg/api/github.go
Normal file
296
pkg/api/github.go
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-i2p/go-github-dashboard/pkg/types"
|
||||||
|
"github.com/google/go-github/v58/github"
|
||||||
|
"github.com/hashicorp/go-retryablehttp"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GitHubClient wraps the GitHub API client with additional functionality
|
||||||
|
type GitHubClient struct {
|
||||||
|
client *github.Client
|
||||||
|
cache *Cache
|
||||||
|
rateLimited bool
|
||||||
|
config *types.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGitHubClient creates a new GitHub API client
|
||||||
|
func NewGitHubClient(config *types.Config, cache *Cache) *GitHubClient {
|
||||||
|
var httpClient *http.Client
|
||||||
|
|
||||||
|
// Create a retry client
|
||||||
|
retryClient := retryablehttp.NewClient()
|
||||||
|
retryClient.RetryMax = 3
|
||||||
|
retryClient.Logger = nil // Disable logging from the retry client
|
||||||
|
|
||||||
|
if config.GithubToken != "" {
|
||||||
|
// If token is provided, use it for authentication
|
||||||
|
ts := oauth2.StaticTokenSource(
|
||||||
|
&oauth2.Token{AccessToken: config.GithubToken},
|
||||||
|
)
|
||||||
|
httpClient = oauth2.NewClient(context.Background(), ts)
|
||||||
|
retryClient.HTTPClient = httpClient
|
||||||
|
}
|
||||||
|
|
||||||
|
client := github.NewClient(retryClient.StandardClient())
|
||||||
|
|
||||||
|
return &GitHubClient{
|
||||||
|
client: client,
|
||||||
|
cache: cache,
|
||||||
|
rateLimited: false,
|
||||||
|
config: config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepositories fetches repositories for a user or organization
|
||||||
|
func (g *GitHubClient) GetRepositories(ctx context.Context) ([]types.Repository, error) {
|
||||||
|
var allRepos []types.Repository
|
||||||
|
|
||||||
|
cacheKey := "repos_"
|
||||||
|
if g.config.User != "" {
|
||||||
|
cacheKey += g.config.User
|
||||||
|
} else {
|
||||||
|
cacheKey += g.config.Organization
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get from cache first
|
||||||
|
if cachedRepos, found := g.cache.Get(cacheKey); found {
|
||||||
|
if g.config.Verbose {
|
||||||
|
log.Println("Using cached repositories")
|
||||||
|
}
|
||||||
|
return cachedRepos.([]types.Repository), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.config.Verbose {
|
||||||
|
log.Println("Fetching repositories from GitHub API")
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
if g.config.User != "" {
|
||||||
|
opts := &github.RepositoryListOptions{
|
||||||
|
ListOptions: github.ListOptions{PerPage: 100},
|
||||||
|
Sort: "updated",
|
||||||
|
}
|
||||||
|
repos, resp, err := g.client.Repositories.List(ctx, g.config.User, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error fetching repositories: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, repo := range repos {
|
||||||
|
allRepos = append(allRepos, convertRepository(repo))
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.NextPage == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
opts.Page = resp.NextPage
|
||||||
|
} else {
|
||||||
|
opts := &github.RepositoryListByOrgOptions{
|
||||||
|
ListOptions: github.ListOptions{PerPage: 100},
|
||||||
|
Sort: "updated",
|
||||||
|
}
|
||||||
|
repos, resp, err := g.client.Repositories.ListByOrg(ctx, g.config.Organization, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error fetching repositories: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, repo := range repos {
|
||||||
|
allRepos = append(allRepos, convertRepository(repo))
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.NextPage == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
opts.Page = resp.NextPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the results
|
||||||
|
g.cache.Set(cacheKey, allRepos)
|
||||||
|
|
||||||
|
return allRepos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPullRequests fetches open pull requests for a repository
|
||||||
|
func (g *GitHubClient) GetPullRequests(ctx context.Context, owner, repo string) ([]types.PullRequest, error) {
|
||||||
|
var allPRs []types.PullRequest
|
||||||
|
cacheKey := fmt.Sprintf("prs_%s_%s", owner, repo)
|
||||||
|
|
||||||
|
// Try to get from cache first
|
||||||
|
if cachedPRs, found := g.cache.Get(cacheKey); found {
|
||||||
|
if g.config.Verbose {
|
||||||
|
log.Printf("Using cached pull requests for %s/%s", owner, repo)
|
||||||
|
}
|
||||||
|
return cachedPRs.([]types.PullRequest), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.config.Verbose {
|
||||||
|
log.Printf("Fetching pull requests for %s/%s", owner, repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := &github.PullRequestListOptions{
|
||||||
|
State: "open",
|
||||||
|
Sort: "updated",
|
||||||
|
Direction: "desc",
|
||||||
|
ListOptions: github.ListOptions{PerPage: 100},
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
prs, resp, err := g.client.PullRequests.List(ctx, owner, repo, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error fetching pull requests: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pr := range prs {
|
||||||
|
allPRs = append(allPRs, convertPullRequest(pr))
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.NextPage == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
opts.Page = resp.NextPage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the results
|
||||||
|
g.cache.Set(cacheKey, allPRs)
|
||||||
|
|
||||||
|
return allPRs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIssues fetches open issues for a repository
|
||||||
|
func (g *GitHubClient) GetIssues(ctx context.Context, owner, repo string) ([]types.Issue, error) {
|
||||||
|
var allIssues []types.Issue
|
||||||
|
cacheKey := fmt.Sprintf("issues_%s_%s", owner, repo)
|
||||||
|
|
||||||
|
// Try to get from cache first
|
||||||
|
if cachedIssues, found := g.cache.Get(cacheKey); found {
|
||||||
|
if g.config.Verbose {
|
||||||
|
log.Printf("Using cached issues for %s/%s", owner, repo)
|
||||||
|
}
|
||||||
|
return cachedIssues.([]types.Issue), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.config.Verbose {
|
||||||
|
log.Printf("Fetching issues for %s/%s", owner, repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := &github.IssueListByRepoOptions{
|
||||||
|
State: "open",
|
||||||
|
Sort: "updated",
|
||||||
|
Direction: "desc",
|
||||||
|
ListOptions: github.ListOptions{PerPage: 100},
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
issues, resp, err := g.client.Issues.ListByRepo(ctx, owner, repo, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error fetching issues: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, issue := range issues {
|
||||||
|
// Skip pull requests (they appear in the issues API)
|
||||||
|
if issue.PullRequestLinks != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
allIssues = append(allIssues, convertIssue(issue))
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.NextPage == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
opts.Page = resp.NextPage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the results
|
||||||
|
g.cache.Set(cacheKey, allIssues)
|
||||||
|
|
||||||
|
return allIssues, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDiscussions fetches recent discussions for a repository
|
||||||
|
func (g *GitHubClient) GetDiscussions(ctx context.Context, owner, repo string) ([]types.Discussion, error) {
|
||||||
|
// Note: The GitHub API v3 doesn't have a direct endpoint for discussions
|
||||||
|
// We'll simulate this functionality by retrieving discussions via RSS feed
|
||||||
|
// This will be implemented in the RSS parser
|
||||||
|
return []types.Discussion{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions to convert GitHub API types to our domain types
|
||||||
|
func convertRepository(repo *github.Repository) types.Repository {
|
||||||
|
r := types.Repository{
|
||||||
|
Name: repo.GetName(),
|
||||||
|
FullName: repo.GetFullName(),
|
||||||
|
Description: repo.GetDescription(),
|
||||||
|
URL: repo.GetHTMLURL(),
|
||||||
|
Owner: repo.GetOwner().GetLogin(),
|
||||||
|
Stars: repo.GetStargazersCount(),
|
||||||
|
Forks: repo.GetForksCount(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if repo.UpdatedAt != nil {
|
||||||
|
r.LastUpdated = repo.UpdatedAt.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertPullRequest(pr *github.PullRequest) types.PullRequest {
|
||||||
|
pullRequest := types.PullRequest{
|
||||||
|
Number: pr.GetNumber(),
|
||||||
|
Title: pr.GetTitle(),
|
||||||
|
URL: pr.GetHTMLURL(),
|
||||||
|
Author: pr.GetUser().GetLogin(),
|
||||||
|
AuthorURL: pr.GetUser().GetHTMLURL(),
|
||||||
|
Status: pr.GetState(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if pr.CreatedAt != nil {
|
||||||
|
pullRequest.CreatedAt = pr.CreatedAt.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
if pr.UpdatedAt != nil {
|
||||||
|
pullRequest.UpdatedAt = pr.UpdatedAt.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, label := range pr.Labels {
|
||||||
|
pullRequest.Labels = append(pullRequest.Labels, types.Label{
|
||||||
|
Name: label.GetName(),
|
||||||
|
Color: label.GetColor(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return pullRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertIssue(issue *github.Issue) types.Issue {
|
||||||
|
i := types.Issue{
|
||||||
|
Number: issue.GetNumber(),
|
||||||
|
Title: issue.GetTitle(),
|
||||||
|
URL: issue.GetHTMLURL(),
|
||||||
|
Author: issue.GetUser().GetLogin(),
|
||||||
|
AuthorURL: issue.GetUser().GetHTMLURL(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if issue.CreatedAt != nil {
|
||||||
|
i.CreatedAt = issue.CreatedAt.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
if issue.UpdatedAt != nil {
|
||||||
|
i.UpdatedAt = issue.UpdatedAt.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, label := range issue.Labels {
|
||||||
|
i.Labels = append(i.Labels, types.Label{
|
||||||
|
Name: label.GetName(),
|
||||||
|
Color: label.GetColor(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return i
|
||||||
|
}
|
284
pkg/api/rss.go
Normal file
284
pkg/api/rss.go
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-i2p/go-github-dashboard/pkg/types"
|
||||||
|
"github.com/mmcdole/gofeed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RSSClient handles fetching data from GitHub RSS feeds
|
||||||
|
type RSSClient struct {
|
||||||
|
parser *gofeed.Parser
|
||||||
|
cache *Cache
|
||||||
|
config *types.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRSSClient creates a new RSS client
|
||||||
|
func NewRSSClient(config *types.Config, cache *Cache) *RSSClient {
|
||||||
|
return &RSSClient{
|
||||||
|
parser: gofeed.NewParser(),
|
||||||
|
cache: cache,
|
||||||
|
config: config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDiscussionsFromRSS fetches recent discussions from GitHub RSS feed
|
||||||
|
func (r *RSSClient) GetDiscussionsFromRSS(ctx context.Context, owner, repo string) ([]types.Discussion, error) {
|
||||||
|
var discussions []types.Discussion
|
||||||
|
cacheKey := fmt.Sprintf("discussions_rss_%s_%s", owner, repo)
|
||||||
|
|
||||||
|
// Try to get from cache first
|
||||||
|
if cachedDiscussions, found := r.cache.Get(cacheKey); found {
|
||||||
|
if r.config.Verbose {
|
||||||
|
log.Printf("Using cached discussions for %s/%s", owner, repo)
|
||||||
|
}
|
||||||
|
return cachedDiscussions.([]types.Discussion), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.config.Verbose {
|
||||||
|
log.Printf("Fetching discussions from RSS for %s/%s", owner, repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitHub discussions RSS feed URL
|
||||||
|
feedURL := fmt.Sprintf("https://github.com/%s/%s/discussions.atom", owner, repo)
|
||||||
|
|
||||||
|
feed, err := r.parser.ParseURLWithContext(feedURL, ctx)
|
||||||
|
if err != nil {
|
||||||
|
// If we can't fetch the feed, just return an empty slice rather than failing
|
||||||
|
if r.config.Verbose {
|
||||||
|
log.Printf("Error fetching discussions feed for %s/%s: %v", owner, repo, err)
|
||||||
|
}
|
||||||
|
return []types.Discussion{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cutoffDate := time.Now().AddDate(0, 0, -30) // Last 30 days
|
||||||
|
|
||||||
|
for _, item := range feed.Items {
|
||||||
|
// Skip items older than 30 days
|
||||||
|
if item.PublishedParsed != nil && item.PublishedParsed.Before(cutoffDate) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the author info
|
||||||
|
author := ""
|
||||||
|
authorURL := ""
|
||||||
|
if item.Author != nil {
|
||||||
|
author = item.Author.Name
|
||||||
|
// Extract the GitHub username from the author name if possible
|
||||||
|
if strings.Contains(author, "(") {
|
||||||
|
parts := strings.Split(author, "(")
|
||||||
|
if len(parts) > 1 {
|
||||||
|
username := strings.TrimSuffix(strings.TrimPrefix(parts[1], "@"), ")")
|
||||||
|
author = username
|
||||||
|
authorURL = fmt.Sprintf("https://github.com/%s", username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the category
|
||||||
|
category := "Discussion"
|
||||||
|
if len(item.Categories) > 0 {
|
||||||
|
category = item.Categories[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
discussion := types.Discussion{
|
||||||
|
Title: item.Title,
|
||||||
|
URL: item.Link,
|
||||||
|
Author: author,
|
||||||
|
AuthorURL: authorURL,
|
||||||
|
Category: category,
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.PublishedParsed != nil {
|
||||||
|
discussion.CreatedAt = *item.PublishedParsed
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.UpdatedParsed != nil {
|
||||||
|
discussion.LastUpdated = *item.UpdatedParsed
|
||||||
|
} else if item.PublishedParsed != nil {
|
||||||
|
discussion.LastUpdated = *item.PublishedParsed
|
||||||
|
}
|
||||||
|
|
||||||
|
discussions = append(discussions, discussion)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the results
|
||||||
|
r.cache.Set(cacheKey, discussions)
|
||||||
|
|
||||||
|
return discussions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIssuesFromRSS fetches recent issues from GitHub RSS feed
|
||||||
|
func (r *RSSClient) GetIssuesFromRSS(ctx context.Context, owner, repo string) ([]types.Issue, error) {
|
||||||
|
var issues []types.Issue
|
||||||
|
cacheKey := fmt.Sprintf("issues_rss_%s_%s", owner, repo)
|
||||||
|
|
||||||
|
// Try to get from cache first
|
||||||
|
if cachedIssues, found := r.cache.Get(cacheKey); found {
|
||||||
|
if r.config.Verbose {
|
||||||
|
log.Printf("Using cached issues from RSS for %s/%s", owner, repo)
|
||||||
|
}
|
||||||
|
return cachedIssues.([]types.Issue), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.config.Verbose {
|
||||||
|
log.Printf("Fetching issues from RSS for %s/%s", owner, repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitHub issues RSS feed URL
|
||||||
|
feedURL := fmt.Sprintf("https://github.com/%s/%s/issues.atom", owner, repo)
|
||||||
|
|
||||||
|
feed, err := r.parser.ParseURLWithContext(feedURL, ctx)
|
||||||
|
if err != nil {
|
||||||
|
// If we can't fetch the feed, just return an empty slice
|
||||||
|
if r.config.Verbose {
|
||||||
|
log.Printf("Error fetching issues feed for %s/%s: %v", owner, repo, err)
|
||||||
|
}
|
||||||
|
return []types.Issue{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range feed.Items {
|
||||||
|
// Skip pull requests (they appear in the issues feed)
|
||||||
|
if strings.Contains(item.Link, "/pull/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the issue number from the URL
|
||||||
|
number := 0
|
||||||
|
parts := strings.Split(item.Link, "/issues/")
|
||||||
|
if len(parts) > 1 {
|
||||||
|
fmt.Sscanf(parts[1], "%d", &number)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the author info
|
||||||
|
author := ""
|
||||||
|
authorURL := ""
|
||||||
|
if item.Author != nil {
|
||||||
|
author = item.Author.Name
|
||||||
|
// Extract the GitHub username from the author name if possible
|
||||||
|
if strings.Contains(author, "(") {
|
||||||
|
parts := strings.Split(author, "(")
|
||||||
|
if len(parts) > 1 {
|
||||||
|
username := strings.TrimSuffix(strings.TrimPrefix(parts[1], "@"), ")")
|
||||||
|
author = username
|
||||||
|
authorURL = fmt.Sprintf("https://github.com/%s", username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
issue := types.Issue{
|
||||||
|
Number: number,
|
||||||
|
Title: item.Title,
|
||||||
|
URL: item.Link,
|
||||||
|
Author: author,
|
||||||
|
AuthorURL: authorURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.PublishedParsed != nil {
|
||||||
|
issue.CreatedAt = *item.PublishedParsed
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.UpdatedParsed != nil {
|
||||||
|
issue.UpdatedAt = *item.UpdatedParsed
|
||||||
|
} else if item.PublishedParsed != nil {
|
||||||
|
issue.UpdatedAt = *item.PublishedParsed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: RSS doesn't include labels, so we'll have an empty labels slice
|
||||||
|
|
||||||
|
issues = append(issues, issue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the results
|
||||||
|
r.cache.Set(cacheKey, issues)
|
||||||
|
|
||||||
|
return issues, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPullRequestsFromRSS fetches recent pull requests from GitHub RSS feed
|
||||||
|
func (r *RSSClient) GetPullRequestsFromRSS(ctx context.Context, owner, repo string) ([]types.PullRequest, error) {
|
||||||
|
var pullRequests []types.PullRequest
|
||||||
|
cacheKey := fmt.Sprintf("prs_rss_%s_%s", owner, repo)
|
||||||
|
|
||||||
|
// Try to get from cache first
|
||||||
|
if cachedPRs, found := r.cache.Get(cacheKey); found {
|
||||||
|
if r.config.Verbose {
|
||||||
|
log.Printf("Using cached pull requests from RSS for %s/%s", owner, repo)
|
||||||
|
}
|
||||||
|
return cachedPRs.([]types.PullRequest), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.config.Verbose {
|
||||||
|
log.Printf("Fetching pull requests from RSS for %s/%s", owner, repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitHub pull requests RSS feed URL
|
||||||
|
feedURL := fmt.Sprintf("https://github.com/%s/%s/pulls.atom", owner, repo)
|
||||||
|
|
||||||
|
feed, err := r.parser.ParseURLWithContext(feedURL, ctx)
|
||||||
|
if err != nil {
|
||||||
|
// If we can't fetch the feed, just return an empty slice
|
||||||
|
if r.config.Verbose {
|
||||||
|
log.Printf("Error fetching pull requests feed for %s/%s: %v", owner, repo, err)
|
||||||
|
}
|
||||||
|
return []types.PullRequest{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range feed.Items {
|
||||||
|
// Parse the PR number from the URL
|
||||||
|
number := 0
|
||||||
|
parts := strings.Split(item.Link, "/pull/")
|
||||||
|
if len(parts) > 1 {
|
||||||
|
fmt.Sscanf(parts[1], "%d", &number)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the author info
|
||||||
|
author := ""
|
||||||
|
authorURL := ""
|
||||||
|
if item.Author != nil {
|
||||||
|
author = item.Author.Name
|
||||||
|
// Extract the GitHub username from the author name if possible
|
||||||
|
if strings.Contains(author, "(") {
|
||||||
|
parts := strings.Split(author, "(")
|
||||||
|
if len(parts) > 1 {
|
||||||
|
username := strings.TrimSuffix(strings.TrimPrefix(parts[1], "@"), ")")
|
||||||
|
author = username
|
||||||
|
authorURL = fmt.Sprintf("https://github.com/%s", username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pr := types.PullRequest{
|
||||||
|
Number: number,
|
||||||
|
Title: item.Title,
|
||||||
|
URL: item.Link,
|
||||||
|
Author: author,
|
||||||
|
AuthorURL: authorURL,
|
||||||
|
Status: "open", // All items in the feed are open
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.PublishedParsed != nil {
|
||||||
|
pr.CreatedAt = *item.PublishedParsed
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.UpdatedParsed != nil {
|
||||||
|
pr.UpdatedAt = *item.UpdatedParsed
|
||||||
|
} else if item.PublishedParsed != nil {
|
||||||
|
pr.UpdatedAt = *item.PublishedParsed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: RSS doesn't include labels, so we'll have an empty labels slice
|
||||||
|
|
||||||
|
pullRequests = append(pullRequests, pr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the results
|
||||||
|
r.cache.Set(cacheKey, pullRequests)
|
||||||
|
|
||||||
|
return pullRequests, nil
|
||||||
|
}
|
219
pkg/cmd/generate.go
Normal file
219
pkg/cmd/generate.go
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
// pkg/cmd/generate.go
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-i2p/go-github-dashboard/pkg/api"
|
||||||
|
"github.com/go-i2p/go-github-dashboard/pkg/config"
|
||||||
|
"github.com/go-i2p/go-github-dashboard/pkg/generator"
|
||||||
|
"github.com/go-i2p/go-github-dashboard/pkg/types"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var generateCmd = &cobra.Command{
|
||||||
|
Use: "generate",
|
||||||
|
Short: "Generate the GitHub dashboard",
|
||||||
|
Long: `Fetches GitHub repository data and generates a static dashboard.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return runGenerate()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(generateCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runGenerate() error {
|
||||||
|
// Get configuration
|
||||||
|
cfg, err := config.GetConfig()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error with configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a context with cancellation
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Handle interruptions gracefully
|
||||||
|
signalChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
go func() {
|
||||||
|
<-signalChan
|
||||||
|
fmt.Println("\nReceived interrupt signal, shutting down gracefully...")
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Initialize the cache
|
||||||
|
cache := api.NewCache(cfg)
|
||||||
|
|
||||||
|
// Initialize clients
|
||||||
|
githubClient := api.NewGitHubClient(cfg, cache)
|
||||||
|
rssClient := api.NewRSSClient(cfg, cache)
|
||||||
|
|
||||||
|
// Initialize generators
|
||||||
|
mdGenerator, err := generator.NewMarkdownGenerator(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error creating markdown generator: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
htmlGenerator, err := generator.NewHTMLGenerator(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error creating HTML generator: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create dashboard data structure
|
||||||
|
dashboard := types.Dashboard{
|
||||||
|
Username: cfg.User,
|
||||||
|
Organization: cfg.Organization,
|
||||||
|
GeneratedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch repositories
|
||||||
|
fmt.Println("Fetching repositories...")
|
||||||
|
repositories, err := githubClient.GetRepositories(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error fetching repositories: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Found %d repositories\n", len(repositories))
|
||||||
|
|
||||||
|
// Create a wait group for parallel processing
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
reposChan := make(chan types.Repository, len(repositories))
|
||||||
|
|
||||||
|
// Process each repository in parallel
|
||||||
|
for i := range repositories {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(repo types.Repository) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
// Check if context is canceled
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
owner := repo.Owner
|
||||||
|
repoName := repo.Name
|
||||||
|
|
||||||
|
fmt.Printf("Processing repository: %s/%s\n", owner, repoName)
|
||||||
|
|
||||||
|
// Fetch pull requests from RSS first, fall back to API
|
||||||
|
pullRequests, err := rssClient.GetPullRequestsFromRSS(ctx, owner, repoName)
|
||||||
|
if err != nil || len(pullRequests) == 0 {
|
||||||
|
if cfg.Verbose && err != nil {
|
||||||
|
log.Printf("Error fetching pull requests from RSS for %s/%s: %v, falling back to API", owner, repoName, err)
|
||||||
|
}
|
||||||
|
pullRequests, err = githubClient.GetPullRequests(ctx, owner, repoName)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error fetching pull requests for %s/%s: %v", owner, repoName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch issues from RSS first, fall back to API
|
||||||
|
issues, err := rssClient.GetIssuesFromRSS(ctx, owner, repoName)
|
||||||
|
if err != nil || len(issues) == 0 {
|
||||||
|
if cfg.Verbose && err != nil {
|
||||||
|
log.Printf("Error fetching issues from RSS for %s/%s: %v, falling back to API", owner, repoName, err)
|
||||||
|
}
|
||||||
|
issues, err = githubClient.GetIssues(ctx, owner, repoName)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error fetching issues for %s/%s: %v", owner, repoName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch discussions (only available through RSS)
|
||||||
|
discussions, err := rssClient.GetDiscussionsFromRSS(ctx, owner, repoName)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error fetching discussions for %s/%s: %v", owner, repoName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the repository with the fetched data
|
||||||
|
repo.PullRequests = pullRequests
|
||||||
|
repo.Issues = issues
|
||||||
|
repo.Discussions = discussions
|
||||||
|
|
||||||
|
// Send the updated repository to the channel
|
||||||
|
reposChan <- repo
|
||||||
|
}(repositories[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the channel when all goroutines are done
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(reposChan)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Collect results
|
||||||
|
var processedRepos []types.Repository
|
||||||
|
for repo := range reposChan {
|
||||||
|
processedRepos = append(processedRepos, repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort repositories by name (for consistent output)
|
||||||
|
dashboard.Repositories = processedRepos
|
||||||
|
|
||||||
|
// Count totals
|
||||||
|
for _, repo := range dashboard.Repositories {
|
||||||
|
dashboard.TotalPRs += len(repo.PullRequests)
|
||||||
|
dashboard.TotalIssues += len(repo.Issues)
|
||||||
|
dashboard.TotalDiscussions += len(repo.Discussions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate markdown files
|
||||||
|
fmt.Println("Generating markdown files...")
|
||||||
|
markdownPaths, err := mdGenerator.GenerateAllRepositoriesMarkdown(dashboard)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error generating markdown files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert markdown to HTML
|
||||||
|
fmt.Println("Converting markdown to HTML...")
|
||||||
|
_, err = htmlGenerator.ConvertAllMarkdownToHTML(markdownPaths)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error converting markdown to HTML: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate the main HTML dashboard
|
||||||
|
fmt.Println("Generating HTML dashboard...")
|
||||||
|
err = htmlGenerator.GenerateHTML(dashboard)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error generating HTML dashboard: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a README in the output directory
|
||||||
|
readmePath := filepath.Join(cfg.OutputDir, "README.md")
|
||||||
|
var targetName string
|
||||||
|
if cfg.User != "" {
|
||||||
|
targetName = "@" + cfg.User
|
||||||
|
} else {
|
||||||
|
targetName = cfg.Organization
|
||||||
|
}
|
||||||
|
|
||||||
|
readme := fmt.Sprintf("# GitHub Dashboard\n\nThis dashboard was generated for %s on %s.\n\n",
|
||||||
|
targetName,
|
||||||
|
dashboard.GeneratedAt.Format("January 2, 2006"))
|
||||||
|
readme += fmt.Sprintf("- Total repositories: %d\n", len(dashboard.Repositories))
|
||||||
|
readme += fmt.Sprintf("- Total open pull requests: %d\n", dashboard.TotalPRs)
|
||||||
|
readme += fmt.Sprintf("- Total open issues: %d\n", dashboard.TotalIssues)
|
||||||
|
readme += fmt.Sprintf("- Total recent discussions: %d\n\n", dashboard.TotalDiscussions)
|
||||||
|
readme += "To view the dashboard, open `index.html` in your browser.\n"
|
||||||
|
|
||||||
|
err = os.WriteFile(readmePath, []byte(readme), 0644)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error writing README file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\nDashboard generated successfully in %s\n", cfg.OutputDir)
|
||||||
|
fmt.Printf("Open %s/index.html in your browser to view the dashboard\n", cfg.OutputDir)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
53
pkg/cmd/main.go
Normal file
53
pkg/cmd/main.go
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
// pkg/cmd/root.go
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/go-i2p/go-github-dashboard/pkg/config"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
var rootCmd = &cobra.Command{
|
||||||
|
Use: "go-github-dashboard",
|
||||||
|
Short: "Generate a static GitHub dashboard",
|
||||||
|
Long: `A pure Go command-line application that generates a static
|
||||||
|
GitHub dashboard by aggregating repository data from GitHub API
|
||||||
|
and RSS feeds, organizing content in a repository-by-repository structure.`,
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
// The root command will just show help
|
||||||
|
cmd.Help()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute executes the root command
|
||||||
|
func Execute() {
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
cobra.OnInitialize(config.InitConfig)
|
||||||
|
|
||||||
|
// Persistent flags for all commands
|
||||||
|
rootCmd.PersistentFlags().StringP("user", "u", "", "GitHub username to generate dashboard for")
|
||||||
|
rootCmd.PersistentFlags().StringP("org", "o", "", "GitHub organization to generate dashboard for")
|
||||||
|
rootCmd.PersistentFlags().StringP("output", "d", "./dashboard", "Output directory for the dashboard")
|
||||||
|
rootCmd.PersistentFlags().StringP("token", "t", "", "GitHub API token (optional, increases rate limits)")
|
||||||
|
rootCmd.PersistentFlags().String("cache-dir", "./.cache", "Directory for caching API responses")
|
||||||
|
rootCmd.PersistentFlags().String("cache-ttl", "1h", "Cache time-to-live duration (e.g., 1h, 30m)")
|
||||||
|
rootCmd.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose output")
|
||||||
|
|
||||||
|
// Bind flags to viper
|
||||||
|
viper.BindPFlag("user", rootCmd.PersistentFlags().Lookup("user"))
|
||||||
|
viper.BindPFlag("org", rootCmd.PersistentFlags().Lookup("org"))
|
||||||
|
viper.BindPFlag("output", rootCmd.PersistentFlags().Lookup("output"))
|
||||||
|
viper.BindPFlag("token", rootCmd.PersistentFlags().Lookup("token"))
|
||||||
|
viper.BindPFlag("cache-dir", rootCmd.PersistentFlags().Lookup("cache-dir"))
|
||||||
|
viper.BindPFlag("cache-ttl", rootCmd.PersistentFlags().Lookup("cache-ttl"))
|
||||||
|
viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose"))
|
||||||
|
}
|
30
pkg/cmd/version.go
Normal file
30
pkg/cmd/version.go
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
// pkg/cmd/version.go
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version information
|
||||||
|
var (
|
||||||
|
Version = "0.1.0"
|
||||||
|
BuildDate = "unknown"
|
||||||
|
Commit = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
var versionCmd = &cobra.Command{
|
||||||
|
Use: "version",
|
||||||
|
Short: "Print the version number",
|
||||||
|
Long: `Print the version, build date, and commit hash.`,
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
fmt.Printf("go-github-dashboard version %s\n", Version)
|
||||||
|
fmt.Printf("Build date: %s\n", BuildDate)
|
||||||
|
fmt.Printf("Commit: %s\n", Commit)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(versionCmd)
|
||||||
|
}
|
76
pkg/config/config.go
Normal file
76
pkg/config/config.go
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-i2p/go-github-dashboard/pkg/types"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InitConfig initializes the Viper configuration
|
||||||
|
func InitConfig() {
|
||||||
|
// Set default values
|
||||||
|
viper.SetDefault("output", "./dashboard")
|
||||||
|
viper.SetDefault("cache-dir", "./.cache")
|
||||||
|
viper.SetDefault("cache-ttl", "1h")
|
||||||
|
viper.SetDefault("verbose", false)
|
||||||
|
|
||||||
|
// Environment variables
|
||||||
|
viper.SetEnvPrefix("GITHUB_DASHBOARD") // will convert to GITHUB_DASHBOARD_*
|
||||||
|
viper.AutomaticEnv()
|
||||||
|
|
||||||
|
// Check for token in environment
|
||||||
|
if token := os.Getenv("GITHUB_TOKEN"); token != "" && viper.GetString("token") == "" {
|
||||||
|
viper.Set("token", token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig builds and validates the configuration from Viper
|
||||||
|
func GetConfig() (*types.Config, error) {
|
||||||
|
cacheTTL, err := time.ParseDuration(viper.GetString("cache-ttl"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("invalid cache-ttl format: use a valid duration string (e.g., 1h, 30m)")
|
||||||
|
}
|
||||||
|
|
||||||
|
config := &types.Config{
|
||||||
|
User: viper.GetString("user"),
|
||||||
|
Organization: viper.GetString("org"),
|
||||||
|
OutputDir: viper.GetString("output"),
|
||||||
|
GithubToken: viper.GetString("token"),
|
||||||
|
CacheDir: viper.GetString("cache-dir"),
|
||||||
|
CacheTTL: cacheTTL,
|
||||||
|
Verbose: viper.GetBool("verbose"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate config
|
||||||
|
if config.User == "" && config.Organization == "" {
|
||||||
|
return nil, errors.New("either user or organization must be specified")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.User != "" && config.Organization != "" {
|
||||||
|
return nil, errors.New("only one of user or organization can be specified")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create output directory if it doesn't exist
|
||||||
|
err = os.MkdirAll(config.OutputDir, 0755)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create repositories directory
|
||||||
|
err = os.MkdirAll(filepath.Join(config.OutputDir, "repositories"), 0755)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create cache directory if it doesn't exist
|
||||||
|
err = os.MkdirAll(config.CacheDir, 0755)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return config, nil
|
||||||
|
}
|
558
pkg/generator/html.go
Normal file
558
pkg/generator/html.go
Normal file
@ -0,0 +1,558 @@
|
|||||||
|
package generator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-i2p/go-github-dashboard/pkg/types"
|
||||||
|
"github.com/russross/blackfriday/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTMLGenerator handles the generation of HTML files
|
||||||
|
type HTMLGenerator struct {
|
||||||
|
outputDir string
|
||||||
|
template *template.Template
|
||||||
|
verbose bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHTMLGenerator creates a new HTMLGenerator
|
||||||
|
func NewHTMLGenerator(config *types.Config) (*HTMLGenerator, error) {
|
||||||
|
// Create the template
|
||||||
|
indexTmpl := `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>GitHub Dashboard {{if .Username}}for @{{.Username}}{{else}}for {{.Organization}}{{end}}</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>GitHub Dashboard {{if .Username}}for <a href="https://github.com/{{.Username}}">@{{.Username}}</a>{{else}}for <a href="https://github.com/{{.Organization}}">{{.Organization}}</a>{{end}}</h1>
|
||||||
|
<div class="dashboard-stats">
|
||||||
|
<span>{{len .Repositories}} repositories</span>
|
||||||
|
<span>{{.TotalPRs}} open pull requests</span>
|
||||||
|
<span>{{.TotalIssues}} open issues</span>
|
||||||
|
<span>{{.TotalDiscussions}} recent discussions</span>
|
||||||
|
</div>
|
||||||
|
<p class="generated-at">Generated on {{.GeneratedAt.Format "January 2, 2006 at 15:04"}}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="repositories">
|
||||||
|
<h2>Repositories</h2>
|
||||||
|
|
||||||
|
{{range .Repositories}}
|
||||||
|
<div class="repository">
|
||||||
|
<div class="collapsible">
|
||||||
|
<input type="checkbox" id="repo-{{.Name}}" class="toggle">
|
||||||
|
<label for="repo-{{.Name}}" class="toggle-label">
|
||||||
|
<span class="repo-name">{{.Name}}</span>
|
||||||
|
<div class="repo-stats">
|
||||||
|
<span class="stat">{{len .PullRequests}} PRs</span>
|
||||||
|
<span class="stat">{{len .Issues}} issues</span>
|
||||||
|
<span class="stat">{{len .Discussions}} discussions</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<div class="collapsible-content">
|
||||||
|
<div class="repo-details">
|
||||||
|
<p class="repo-description">{{if .Description}}{{.Description}}{{else}}No description provided.{{end}}</p>
|
||||||
|
<div class="repo-meta">
|
||||||
|
<a href="{{.URL}}" target="_blank">View on GitHub</a>
|
||||||
|
<span>⭐ {{.Stars}}</span>
|
||||||
|
<span>🍴 {{.Forks}}</span>
|
||||||
|
<span>Updated: {{.LastUpdated.Format "2006-01-02"}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .PullRequests}}
|
||||||
|
<div class="collapsible">
|
||||||
|
<input type="checkbox" id="prs-{{.Name}}" class="toggle">
|
||||||
|
<label for="prs-{{.Name}}" class="toggle-label section-label pr-label">
|
||||||
|
Open Pull Requests ({{len .PullRequests}})
|
||||||
|
</label>
|
||||||
|
<div class="collapsible-content">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Author</th>
|
||||||
|
<th>Updated</th>
|
||||||
|
<th>Labels</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .PullRequests}}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{.URL}}" target="_blank">{{.Title}}</a></td>
|
||||||
|
<td><a href="{{.AuthorURL}}" target="_blank">@{{.Author}}</a></td>
|
||||||
|
<td>{{.UpdatedAt.Format "2006-01-02"}}</td>
|
||||||
|
<td>{{range $i, $label := .Labels}}{{if $i}}, {{end}}{{$label.Name}}{{else}}<em>none</em>{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Issues}}
|
||||||
|
<div class="collapsible">
|
||||||
|
<input type="checkbox" id="issues-{{.Name}}" class="toggle">
|
||||||
|
<label for="issues-{{.Name}}" class="toggle-label section-label issue-label">
|
||||||
|
Open Issues ({{len .Issues}})
|
||||||
|
</label>
|
||||||
|
<div class="collapsible-content">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Author</th>
|
||||||
|
<th>Updated</th>
|
||||||
|
<th>Labels</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Issues}}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{.URL}}" target="_blank">{{.Title}}</a></td>
|
||||||
|
<td><a href="{{.AuthorURL}}" target="_blank">@{{.Author}}</a></td>
|
||||||
|
<td>{{.UpdatedAt.Format "2006-01-02"}}</td>
|
||||||
|
<td>{{range $i, $label := .Labels}}{{if $i}}, {{end}}{{$label.Name}}{{else}}<em>none</em>{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Discussions}}
|
||||||
|
<div class="collapsible">
|
||||||
|
<input type="checkbox" id="discussions-{{.Name}}" class="toggle">
|
||||||
|
<label for="discussions-{{.Name}}" class="toggle-label section-label discussion-label">
|
||||||
|
Recent Discussions ({{len .Discussions}})
|
||||||
|
</label>
|
||||||
|
<div class="collapsible-content">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Started By</th>
|
||||||
|
<th>Last Activity</th>
|
||||||
|
<th>Category</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Discussions}}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{.URL}}" target="_blank">{{.Title}}</a></td>
|
||||||
|
<td><a href="{{.AuthorURL}}" target="_blank">@{{.Author}}</a></td>
|
||||||
|
<td>{{.LastUpdated.Format "2006-01-02"}}</td>
|
||||||
|
<td>{{.Category}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="repo-links">
|
||||||
|
<a href="repositories/{{.Name}}.md">View as Markdown</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>Generated with <a href="https://github.com/yourusername/go-github-dashboard">go-github-dashboard</a></p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
tmpl, err := template.New("index.html").Parse(indexTmpl)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error parsing HTML template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &HTMLGenerator{
|
||||||
|
outputDir: config.OutputDir,
|
||||||
|
template: tmpl,
|
||||||
|
verbose: config.Verbose,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateCSS generates the CSS file for the dashboard
|
||||||
|
func (g *HTMLGenerator) GenerateCSS() error {
|
||||||
|
css := `/* Base styles */
|
||||||
|
:root {
|
||||||
|
--primary-color: #0366d6;
|
||||||
|
--secondary-color: #586069;
|
||||||
|
--background-color: #ffffff;
|
||||||
|
--border-color: #e1e4e8;
|
||||||
|
--pr-color: #28a745;
|
||||||
|
--issue-color: #d73a49;
|
||||||
|
--discussion-color: #6f42c1;
|
||||||
|
--hover-color: #f6f8fa;
|
||||||
|
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-family);
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #24292e;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header styles */
|
||||||
|
header {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stats span {
|
||||||
|
background-color: #f1f8ff;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generated-at {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Repository styles */
|
||||||
|
.repositories {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repositories h2 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repository {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-details {
|
||||||
|
padding: 15px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-description {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 15px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-links {
|
||||||
|
padding: 10px 15px;
|
||||||
|
font-size: 14px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapsible sections */
|
||||||
|
.collapsible {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-label {
|
||||||
|
color: var(--pr-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-label {
|
||||||
|
color: var(--issue-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.discussion-label {
|
||||||
|
color: var(--discussion-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label::after {
|
||||||
|
content: '+';
|
||||||
|
font-size: 18px;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle:checked ~ .toggle-label::after {
|
||||||
|
content: '−';
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible-content {
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.35s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle:checked ~ .collapsible-content {
|
||||||
|
max-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table styles */
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th,
|
||||||
|
.data-table td {
|
||||||
|
padding: 8px 15px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tr:hover {
|
||||||
|
background-color: var(--hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
a {
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Repository name and stats */
|
||||||
|
.repo-name {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background-color: #f1f8ff;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
footer {
|
||||||
|
margin-top: 40px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--secondary-color);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.toggle-label {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-stats {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stats {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
err := os.WriteFile(filepath.Join(g.outputDir, "style.css"), []byte(css), 0644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error writing CSS file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateHTML generates the main HTML dashboard
|
||||||
|
func (g *HTMLGenerator) GenerateHTML(dashboard types.Dashboard) error {
|
||||||
|
if g.verbose {
|
||||||
|
log.Println("Generating HTML dashboard")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the template
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := g.template.Execute(&buf, dashboard)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error executing template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the file
|
||||||
|
outputPath := filepath.Join(g.outputDir, "index.html")
|
||||||
|
err = os.WriteFile(outputPath, buf.Bytes(), 0644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error writing HTML file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate the CSS file
|
||||||
|
err = g.GenerateCSS()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvertMarkdownToHTML converts a markdown file to HTML
|
||||||
|
func (g *HTMLGenerator) ConvertMarkdownToHTML(markdownPath string) (string, error) {
|
||||||
|
if g.verbose {
|
||||||
|
log.Printf("Converting markdown to HTML: %s", markdownPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the markdown file
|
||||||
|
markdownContent, err := os.ReadFile(markdownPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error reading markdown file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert the markdown to HTML
|
||||||
|
htmlContent := blackfriday.Run(markdownContent)
|
||||||
|
|
||||||
|
// Determine the output filename
|
||||||
|
baseName := filepath.Base(markdownPath)
|
||||||
|
htmlFileName := strings.TrimSuffix(baseName, filepath.Ext(baseName)) + ".html"
|
||||||
|
htmlPath := filepath.Join(g.outputDir, "repositories", htmlFileName)
|
||||||
|
|
||||||
|
// Create a simple HTML wrapper
|
||||||
|
htmlPage := fmt.Sprintf(`<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>%s</title>
|
||||||
|
<link rel="stylesheet" href="../style.css">
|
||||||
|
<style>
|
||||||
|
.markdown-body {
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.markdown-body table {
|
||||||
|
width: 100%%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.markdown-body th, .markdown-body td {
|
||||||
|
padding: 8px 15px;
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.markdown-body th {
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
}
|
||||||
|
.back-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a href="../index.html" class="back-link">← Back to Dashboard</a>
|
||||||
|
<div class="markdown-body">
|
||||||
|
%s
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`, strings.TrimSuffix(baseName, filepath.Ext(baseName)), string(htmlContent))
|
||||||
|
|
||||||
|
// Write the HTML file
|
||||||
|
err = os.WriteFile(htmlPath, []byte(htmlPage), 0644)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error writing HTML file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return htmlPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvertAllMarkdownToHTML converts all markdown files to HTML
|
||||||
|
func (g *HTMLGenerator) ConvertAllMarkdownToHTML(markdownPaths []string) ([]string, error) {
|
||||||
|
var htmlPaths []string
|
||||||
|
|
||||||
|
for _, markdownPath := range markdownPaths {
|
||||||
|
htmlPath, err := g.ConvertMarkdownToHTML(markdownPath)
|
||||||
|
if err != nil {
|
||||||
|
return htmlPaths, err
|
||||||
|
}
|
||||||
|
htmlPaths = append(htmlPaths, htmlPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return htmlPaths, nil
|
||||||
|
}
|
121
pkg/generator/markdown.go
Normal file
121
pkg/generator/markdown.go
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
package generator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"text/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-i2p/go-github-dashboard/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MarkdownGenerator handles the generation of markdown files
|
||||||
|
type MarkdownGenerator struct {
|
||||||
|
outputDir string
|
||||||
|
template *template.Template
|
||||||
|
verbose bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMarkdownGenerator creates a new MarkdownGenerator
|
||||||
|
func NewMarkdownGenerator(config *types.Config) (*MarkdownGenerator, error) {
|
||||||
|
// Create the template
|
||||||
|
tmpl, err := template.New("repository.md.tmpl").Parse(`# Repository: {{.Name}}
|
||||||
|
|
||||||
|
{{if .Description}}{{.Description}}{{else}}No description provided.{{end}}
|
||||||
|
|
||||||
|
## Open Pull Requests
|
||||||
|
|
||||||
|
{{if .PullRequests}}
|
||||||
|
| Title | Author | Updated | Labels |
|
||||||
|
|-------|--------|---------|--------|
|
||||||
|
{{range .PullRequests}}| [{{.Title}}]({{.URL}}) | [{{.Author}}]({{.AuthorURL}}) | {{.UpdatedAt.Format "2006-01-02"}} | {{range $i, $label := .Labels}}{{if $i}}, {{end}}{{$label.Name}}{{else}}*none*{{end}} |
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
*No open pull requests*
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
## Open Issues
|
||||||
|
|
||||||
|
{{if .Issues}}
|
||||||
|
| Title | Author | Updated | Labels |
|
||||||
|
|-------|--------|---------|--------|
|
||||||
|
{{range .Issues}}| [{{.Title}}]({{.URL}}) | [{{.Author}}]({{.AuthorURL}}) | {{.UpdatedAt.Format "2006-01-02"}} | {{range $i, $label := .Labels}}{{if $i}}, {{end}}{{$label.Name}}{{else}}*none*{{end}} |
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
*No open issues*
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
## Recent Discussions
|
||||||
|
|
||||||
|
{{if .Discussions}}
|
||||||
|
| Title | Started By | Last Activity | Category |
|
||||||
|
|-------|------------|---------------|----------|
|
||||||
|
{{range .Discussions}}| [{{.Title}}]({{.URL}}) | [{{.Author}}]({{.AuthorURL}}) | {{.LastUpdated.Format "2006-01-02"}} | {{.Category}} |
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
*No recent discussions*
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
---
|
||||||
|
*Generated at {{.GeneratedAt.Format "2006-01-02 15:04:05"}}*
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error parsing markdown template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &MarkdownGenerator{
|
||||||
|
outputDir: config.OutputDir,
|
||||||
|
template: tmpl,
|
||||||
|
verbose: config.Verbose,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateRepositoryMarkdown generates a markdown file for a repository
|
||||||
|
func (g *MarkdownGenerator) GenerateRepositoryMarkdown(repo types.Repository) (string, error) {
|
||||||
|
if g.verbose {
|
||||||
|
log.Printf("Generating markdown for repository %s", repo.FullName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare the template data
|
||||||
|
data := struct {
|
||||||
|
types.Repository
|
||||||
|
GeneratedAt time.Time
|
||||||
|
}{
|
||||||
|
Repository: repo,
|
||||||
|
GeneratedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the template
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := g.template.Execute(&buf, data)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error executing template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the file
|
||||||
|
outputPath := filepath.Join(g.outputDir, "repositories", fmt.Sprintf("%s.md", repo.Name))
|
||||||
|
err = os.WriteFile(outputPath, buf.Bytes(), 0644)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error writing markdown file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateAllRepositoriesMarkdown generates markdown files for all repositories
|
||||||
|
func (g *MarkdownGenerator) GenerateAllRepositoriesMarkdown(dashboard types.Dashboard) ([]string, error) {
|
||||||
|
var filePaths []string
|
||||||
|
|
||||||
|
for _, repo := range dashboard.Repositories {
|
||||||
|
path, err := g.GenerateRepositoryMarkdown(repo)
|
||||||
|
if err != nil {
|
||||||
|
return filePaths, err
|
||||||
|
}
|
||||||
|
filePaths = append(filePaths, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filePaths, nil
|
||||||
|
}
|
Reference in New Issue
Block a user