basic idea...

This commit is contained in:
eyedeekay
2025-05-05 17:05:43 -04:00
parent 13800ab183
commit c25e3688f3
12 changed files with 1924 additions and 0 deletions

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}