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

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
}