mirror of
https://github.com/go-i2p/go-github-dashboard.git
synced 2025-06-07 18:24:23 -04:00
basic idea...
This commit is contained in:
137
pkg/api/cache.go
Normal file
137
pkg/api/cache.go
Normal file
@ -0,0 +1,137 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-i2p/go-github-dashboard/pkg/types"
|
||||
)
|
||||
|
||||
// CacheItem represents a cached item with its expiration time
|
||||
type CacheItem struct {
|
||||
Value interface{}
|
||||
Expiration time.Time
|
||||
}
|
||||
|
||||
// Cache provides a simple caching mechanism for API responses
|
||||
type Cache struct {
|
||||
items map[string]CacheItem
|
||||
mutex sync.RWMutex
|
||||
dir string
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// NewCache creates a new cache with the given directory and TTL
|
||||
func NewCache(config *types.Config) *Cache {
|
||||
// Register types for gob encoding
|
||||
gob.Register([]types.Repository{})
|
||||
gob.Register([]types.PullRequest{})
|
||||
gob.Register([]types.Issue{})
|
||||
gob.Register([]types.Discussion{})
|
||||
|
||||
cache := &Cache{
|
||||
items: make(map[string]CacheItem),
|
||||
dir: config.CacheDir,
|
||||
ttl: config.CacheTTL,
|
||||
}
|
||||
|
||||
// Load cache from disk
|
||||
cache.loadCache()
|
||||
|
||||
return cache
|
||||
}
|
||||
|
||||
// Get retrieves a value from the cache
|
||||
func (c *Cache) Get(key string) (interface{}, bool) {
|
||||
c.mutex.RLock()
|
||||
defer c.mutex.RUnlock()
|
||||
|
||||
item, found := c.items[key]
|
||||
if !found {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Check if the item has expired
|
||||
if time.Now().After(item.Expiration) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return item.Value, true
|
||||
}
|
||||
|
||||
// Set stores a value in the cache
|
||||
func (c *Cache) Set(key string, value interface{}) {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
c.items[key] = CacheItem{
|
||||
Value: value,
|
||||
Expiration: time.Now().Add(c.ttl),
|
||||
}
|
||||
|
||||
// Save the cache to disk (in a separate goroutine to avoid blocking)
|
||||
go c.saveCache()
|
||||
}
|
||||
|
||||
// Clear clears all items from the cache
|
||||
func (c *Cache) Clear() {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
c.items = make(map[string]CacheItem)
|
||||
go c.saveCache()
|
||||
}
|
||||
|
||||
// saveCache saves the cache to disk
|
||||
func (c *Cache) saveCache() {
|
||||
c.mutex.RLock()
|
||||
defer c.mutex.RUnlock()
|
||||
|
||||
cacheFile := filepath.Join(c.dir, "cache.gob")
|
||||
file, err := os.Create(cacheFile)
|
||||
if err != nil {
|
||||
fmt.Printf("Error creating cache file: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
encoder := gob.NewEncoder(file)
|
||||
err = encoder.Encode(c.items)
|
||||
if err != nil {
|
||||
fmt.Printf("Error encoding cache: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// loadCache loads the cache from disk
|
||||
func (c *Cache) loadCache() {
|
||||
cacheFile := filepath.Join(c.dir, "cache.gob")
|
||||
file, err := os.Open(cacheFile)
|
||||
if err != nil {
|
||||
// If the file doesn't exist, that's not an error
|
||||
if !os.IsNotExist(err) {
|
||||
fmt.Printf("Error opening cache file: %v\n", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
decoder := gob.NewDecoder(file)
|
||||
err = decoder.Decode(&c.items)
|
||||
if err != nil {
|
||||
fmt.Printf("Error decoding cache: %v\n", err)
|
||||
// If there's an error decoding, start with a fresh cache
|
||||
c.items = make(map[string]CacheItem)
|
||||
}
|
||||
|
||||
// Remove expired items
|
||||
now := time.Now()
|
||||
for key, item := range c.items {
|
||||
if now.After(item.Expiration) {
|
||||
delete(c.items, key)
|
||||
}
|
||||
}
|
||||
}
|
296
pkg/api/github.go
Normal file
296
pkg/api/github.go
Normal file
@ -0,0 +1,296 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-i2p/go-github-dashboard/pkg/types"
|
||||
"github.com/google/go-github/v58/github"
|
||||
"github.com/hashicorp/go-retryablehttp"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// GitHubClient wraps the GitHub API client with additional functionality
|
||||
type GitHubClient struct {
|
||||
client *github.Client
|
||||
cache *Cache
|
||||
rateLimited bool
|
||||
config *types.Config
|
||||
}
|
||||
|
||||
// NewGitHubClient creates a new GitHub API client
|
||||
func NewGitHubClient(config *types.Config, cache *Cache) *GitHubClient {
|
||||
var httpClient *http.Client
|
||||
|
||||
// Create a retry client
|
||||
retryClient := retryablehttp.NewClient()
|
||||
retryClient.RetryMax = 3
|
||||
retryClient.Logger = nil // Disable logging from the retry client
|
||||
|
||||
if config.GithubToken != "" {
|
||||
// If token is provided, use it for authentication
|
||||
ts := oauth2.StaticTokenSource(
|
||||
&oauth2.Token{AccessToken: config.GithubToken},
|
||||
)
|
||||
httpClient = oauth2.NewClient(context.Background(), ts)
|
||||
retryClient.HTTPClient = httpClient
|
||||
}
|
||||
|
||||
client := github.NewClient(retryClient.StandardClient())
|
||||
|
||||
return &GitHubClient{
|
||||
client: client,
|
||||
cache: cache,
|
||||
rateLimited: false,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// GetRepositories fetches repositories for a user or organization
|
||||
func (g *GitHubClient) GetRepositories(ctx context.Context) ([]types.Repository, error) {
|
||||
var allRepos []types.Repository
|
||||
|
||||
cacheKey := "repos_"
|
||||
if g.config.User != "" {
|
||||
cacheKey += g.config.User
|
||||
} else {
|
||||
cacheKey += g.config.Organization
|
||||
}
|
||||
|
||||
// Try to get from cache first
|
||||
if cachedRepos, found := g.cache.Get(cacheKey); found {
|
||||
if g.config.Verbose {
|
||||
log.Println("Using cached repositories")
|
||||
}
|
||||
return cachedRepos.([]types.Repository), nil
|
||||
}
|
||||
|
||||
if g.config.Verbose {
|
||||
log.Println("Fetching repositories from GitHub API")
|
||||
}
|
||||
|
||||
for {
|
||||
if g.config.User != "" {
|
||||
opts := &github.RepositoryListOptions{
|
||||
ListOptions: github.ListOptions{PerPage: 100},
|
||||
Sort: "updated",
|
||||
}
|
||||
repos, resp, err := g.client.Repositories.List(ctx, g.config.User, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error fetching repositories: %w", err)
|
||||
}
|
||||
|
||||
for _, repo := range repos {
|
||||
allRepos = append(allRepos, convertRepository(repo))
|
||||
}
|
||||
|
||||
if resp.NextPage == 0 {
|
||||
break
|
||||
}
|
||||
opts.Page = resp.NextPage
|
||||
} else {
|
||||
opts := &github.RepositoryListByOrgOptions{
|
||||
ListOptions: github.ListOptions{PerPage: 100},
|
||||
Sort: "updated",
|
||||
}
|
||||
repos, resp, err := g.client.Repositories.ListByOrg(ctx, g.config.Organization, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error fetching repositories: %w", err)
|
||||
}
|
||||
|
||||
for _, repo := range repos {
|
||||
allRepos = append(allRepos, convertRepository(repo))
|
||||
}
|
||||
|
||||
if resp.NextPage == 0 {
|
||||
break
|
||||
}
|
||||
opts.Page = resp.NextPage
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the results
|
||||
g.cache.Set(cacheKey, allRepos)
|
||||
|
||||
return allRepos, nil
|
||||
}
|
||||
|
||||
// GetPullRequests fetches open pull requests for a repository
|
||||
func (g *GitHubClient) GetPullRequests(ctx context.Context, owner, repo string) ([]types.PullRequest, error) {
|
||||
var allPRs []types.PullRequest
|
||||
cacheKey := fmt.Sprintf("prs_%s_%s", owner, repo)
|
||||
|
||||
// Try to get from cache first
|
||||
if cachedPRs, found := g.cache.Get(cacheKey); found {
|
||||
if g.config.Verbose {
|
||||
log.Printf("Using cached pull requests for %s/%s", owner, repo)
|
||||
}
|
||||
return cachedPRs.([]types.PullRequest), nil
|
||||
}
|
||||
|
||||
if g.config.Verbose {
|
||||
log.Printf("Fetching pull requests for %s/%s", owner, repo)
|
||||
}
|
||||
|
||||
opts := &github.PullRequestListOptions{
|
||||
State: "open",
|
||||
Sort: "updated",
|
||||
Direction: "desc",
|
||||
ListOptions: github.ListOptions{PerPage: 100},
|
||||
}
|
||||
|
||||
for {
|
||||
prs, resp, err := g.client.PullRequests.List(ctx, owner, repo, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error fetching pull requests: %w", err)
|
||||
}
|
||||
|
||||
for _, pr := range prs {
|
||||
allPRs = append(allPRs, convertPullRequest(pr))
|
||||
}
|
||||
|
||||
if resp.NextPage == 0 {
|
||||
break
|
||||
}
|
||||
opts.Page = resp.NextPage
|
||||
}
|
||||
|
||||
// Cache the results
|
||||
g.cache.Set(cacheKey, allPRs)
|
||||
|
||||
return allPRs, nil
|
||||
}
|
||||
|
||||
// GetIssues fetches open issues for a repository
|
||||
func (g *GitHubClient) GetIssues(ctx context.Context, owner, repo string) ([]types.Issue, error) {
|
||||
var allIssues []types.Issue
|
||||
cacheKey := fmt.Sprintf("issues_%s_%s", owner, repo)
|
||||
|
||||
// Try to get from cache first
|
||||
if cachedIssues, found := g.cache.Get(cacheKey); found {
|
||||
if g.config.Verbose {
|
||||
log.Printf("Using cached issues for %s/%s", owner, repo)
|
||||
}
|
||||
return cachedIssues.([]types.Issue), nil
|
||||
}
|
||||
|
||||
if g.config.Verbose {
|
||||
log.Printf("Fetching issues for %s/%s", owner, repo)
|
||||
}
|
||||
|
||||
opts := &github.IssueListByRepoOptions{
|
||||
State: "open",
|
||||
Sort: "updated",
|
||||
Direction: "desc",
|
||||
ListOptions: github.ListOptions{PerPage: 100},
|
||||
}
|
||||
|
||||
for {
|
||||
issues, resp, err := g.client.Issues.ListByRepo(ctx, owner, repo, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error fetching issues: %w", err)
|
||||
}
|
||||
|
||||
for _, issue := range issues {
|
||||
// Skip pull requests (they appear in the issues API)
|
||||
if issue.PullRequestLinks != nil {
|
||||
continue
|
||||
}
|
||||
allIssues = append(allIssues, convertIssue(issue))
|
||||
}
|
||||
|
||||
if resp.NextPage == 0 {
|
||||
break
|
||||
}
|
||||
opts.Page = resp.NextPage
|
||||
}
|
||||
|
||||
// Cache the results
|
||||
g.cache.Set(cacheKey, allIssues)
|
||||
|
||||
return allIssues, nil
|
||||
}
|
||||
|
||||
// GetDiscussions fetches recent discussions for a repository
|
||||
func (g *GitHubClient) GetDiscussions(ctx context.Context, owner, repo string) ([]types.Discussion, error) {
|
||||
// Note: The GitHub API v3 doesn't have a direct endpoint for discussions
|
||||
// We'll simulate this functionality by retrieving discussions via RSS feed
|
||||
// This will be implemented in the RSS parser
|
||||
return []types.Discussion{}, nil
|
||||
}
|
||||
|
||||
// Helper functions to convert GitHub API types to our domain types
|
||||
func convertRepository(repo *github.Repository) types.Repository {
|
||||
r := types.Repository{
|
||||
Name: repo.GetName(),
|
||||
FullName: repo.GetFullName(),
|
||||
Description: repo.GetDescription(),
|
||||
URL: repo.GetHTMLURL(),
|
||||
Owner: repo.GetOwner().GetLogin(),
|
||||
Stars: repo.GetStargazersCount(),
|
||||
Forks: repo.GetForksCount(),
|
||||
}
|
||||
|
||||
if repo.UpdatedAt != nil {
|
||||
r.LastUpdated = repo.UpdatedAt.Time
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func convertPullRequest(pr *github.PullRequest) types.PullRequest {
|
||||
pullRequest := types.PullRequest{
|
||||
Number: pr.GetNumber(),
|
||||
Title: pr.GetTitle(),
|
||||
URL: pr.GetHTMLURL(),
|
||||
Author: pr.GetUser().GetLogin(),
|
||||
AuthorURL: pr.GetUser().GetHTMLURL(),
|
||||
Status: pr.GetState(),
|
||||
}
|
||||
|
||||
if pr.CreatedAt != nil {
|
||||
pullRequest.CreatedAt = pr.CreatedAt.Time
|
||||
}
|
||||
|
||||
if pr.UpdatedAt != nil {
|
||||
pullRequest.UpdatedAt = pr.UpdatedAt.Time
|
||||
}
|
||||
|
||||
for _, label := range pr.Labels {
|
||||
pullRequest.Labels = append(pullRequest.Labels, types.Label{
|
||||
Name: label.GetName(),
|
||||
Color: label.GetColor(),
|
||||
})
|
||||
}
|
||||
|
||||
return pullRequest
|
||||
}
|
||||
|
||||
func convertIssue(issue *github.Issue) types.Issue {
|
||||
i := types.Issue{
|
||||
Number: issue.GetNumber(),
|
||||
Title: issue.GetTitle(),
|
||||
URL: issue.GetHTMLURL(),
|
||||
Author: issue.GetUser().GetLogin(),
|
||||
AuthorURL: issue.GetUser().GetHTMLURL(),
|
||||
}
|
||||
|
||||
if issue.CreatedAt != nil {
|
||||
i.CreatedAt = issue.CreatedAt.Time
|
||||
}
|
||||
|
||||
if issue.UpdatedAt != nil {
|
||||
i.UpdatedAt = issue.UpdatedAt.Time
|
||||
}
|
||||
|
||||
for _, label := range issue.Labels {
|
||||
i.Labels = append(i.Labels, types.Label{
|
||||
Name: label.GetName(),
|
||||
Color: label.GetColor(),
|
||||
})
|
||||
}
|
||||
|
||||
return i
|
||||
}
|
284
pkg/api/rss.go
Normal file
284
pkg/api/rss.go
Normal file
@ -0,0 +1,284 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-i2p/go-github-dashboard/pkg/types"
|
||||
"github.com/mmcdole/gofeed"
|
||||
)
|
||||
|
||||
// RSSClient handles fetching data from GitHub RSS feeds
|
||||
type RSSClient struct {
|
||||
parser *gofeed.Parser
|
||||
cache *Cache
|
||||
config *types.Config
|
||||
}
|
||||
|
||||
// NewRSSClient creates a new RSS client
|
||||
func NewRSSClient(config *types.Config, cache *Cache) *RSSClient {
|
||||
return &RSSClient{
|
||||
parser: gofeed.NewParser(),
|
||||
cache: cache,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// GetDiscussionsFromRSS fetches recent discussions from GitHub RSS feed
|
||||
func (r *RSSClient) GetDiscussionsFromRSS(ctx context.Context, owner, repo string) ([]types.Discussion, error) {
|
||||
var discussions []types.Discussion
|
||||
cacheKey := fmt.Sprintf("discussions_rss_%s_%s", owner, repo)
|
||||
|
||||
// Try to get from cache first
|
||||
if cachedDiscussions, found := r.cache.Get(cacheKey); found {
|
||||
if r.config.Verbose {
|
||||
log.Printf("Using cached discussions for %s/%s", owner, repo)
|
||||
}
|
||||
return cachedDiscussions.([]types.Discussion), nil
|
||||
}
|
||||
|
||||
if r.config.Verbose {
|
||||
log.Printf("Fetching discussions from RSS for %s/%s", owner, repo)
|
||||
}
|
||||
|
||||
// GitHub discussions RSS feed URL
|
||||
feedURL := fmt.Sprintf("https://github.com/%s/%s/discussions.atom", owner, repo)
|
||||
|
||||
feed, err := r.parser.ParseURLWithContext(feedURL, ctx)
|
||||
if err != nil {
|
||||
// If we can't fetch the feed, just return an empty slice rather than failing
|
||||
if r.config.Verbose {
|
||||
log.Printf("Error fetching discussions feed for %s/%s: %v", owner, repo, err)
|
||||
}
|
||||
return []types.Discussion{}, nil
|
||||
}
|
||||
|
||||
cutoffDate := time.Now().AddDate(0, 0, -30) // Last 30 days
|
||||
|
||||
for _, item := range feed.Items {
|
||||
// Skip items older than 30 days
|
||||
if item.PublishedParsed != nil && item.PublishedParsed.Before(cutoffDate) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse the author info
|
||||
author := ""
|
||||
authorURL := ""
|
||||
if item.Author != nil {
|
||||
author = item.Author.Name
|
||||
// Extract the GitHub username from the author name if possible
|
||||
if strings.Contains(author, "(") {
|
||||
parts := strings.Split(author, "(")
|
||||
if len(parts) > 1 {
|
||||
username := strings.TrimSuffix(strings.TrimPrefix(parts[1], "@"), ")")
|
||||
author = username
|
||||
authorURL = fmt.Sprintf("https://github.com/%s", username)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the category
|
||||
category := "Discussion"
|
||||
if len(item.Categories) > 0 {
|
||||
category = item.Categories[0]
|
||||
}
|
||||
|
||||
discussion := types.Discussion{
|
||||
Title: item.Title,
|
||||
URL: item.Link,
|
||||
Author: author,
|
||||
AuthorURL: authorURL,
|
||||
Category: category,
|
||||
}
|
||||
|
||||
if item.PublishedParsed != nil {
|
||||
discussion.CreatedAt = *item.PublishedParsed
|
||||
}
|
||||
|
||||
if item.UpdatedParsed != nil {
|
||||
discussion.LastUpdated = *item.UpdatedParsed
|
||||
} else if item.PublishedParsed != nil {
|
||||
discussion.LastUpdated = *item.PublishedParsed
|
||||
}
|
||||
|
||||
discussions = append(discussions, discussion)
|
||||
}
|
||||
|
||||
// Cache the results
|
||||
r.cache.Set(cacheKey, discussions)
|
||||
|
||||
return discussions, nil
|
||||
}
|
||||
|
||||
// GetIssuesFromRSS fetches recent issues from GitHub RSS feed
|
||||
func (r *RSSClient) GetIssuesFromRSS(ctx context.Context, owner, repo string) ([]types.Issue, error) {
|
||||
var issues []types.Issue
|
||||
cacheKey := fmt.Sprintf("issues_rss_%s_%s", owner, repo)
|
||||
|
||||
// Try to get from cache first
|
||||
if cachedIssues, found := r.cache.Get(cacheKey); found {
|
||||
if r.config.Verbose {
|
||||
log.Printf("Using cached issues from RSS for %s/%s", owner, repo)
|
||||
}
|
||||
return cachedIssues.([]types.Issue), nil
|
||||
}
|
||||
|
||||
if r.config.Verbose {
|
||||
log.Printf("Fetching issues from RSS for %s/%s", owner, repo)
|
||||
}
|
||||
|
||||
// GitHub issues RSS feed URL
|
||||
feedURL := fmt.Sprintf("https://github.com/%s/%s/issues.atom", owner, repo)
|
||||
|
||||
feed, err := r.parser.ParseURLWithContext(feedURL, ctx)
|
||||
if err != nil {
|
||||
// If we can't fetch the feed, just return an empty slice
|
||||
if r.config.Verbose {
|
||||
log.Printf("Error fetching issues feed for %s/%s: %v", owner, repo, err)
|
||||
}
|
||||
return []types.Issue{}, nil
|
||||
}
|
||||
|
||||
for _, item := range feed.Items {
|
||||
// Skip pull requests (they appear in the issues feed)
|
||||
if strings.Contains(item.Link, "/pull/") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse the issue number from the URL
|
||||
number := 0
|
||||
parts := strings.Split(item.Link, "/issues/")
|
||||
if len(parts) > 1 {
|
||||
fmt.Sscanf(parts[1], "%d", &number)
|
||||
}
|
||||
|
||||
// Parse the author info
|
||||
author := ""
|
||||
authorURL := ""
|
||||
if item.Author != nil {
|
||||
author = item.Author.Name
|
||||
// Extract the GitHub username from the author name if possible
|
||||
if strings.Contains(author, "(") {
|
||||
parts := strings.Split(author, "(")
|
||||
if len(parts) > 1 {
|
||||
username := strings.TrimSuffix(strings.TrimPrefix(parts[1], "@"), ")")
|
||||
author = username
|
||||
authorURL = fmt.Sprintf("https://github.com/%s", username)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
issue := types.Issue{
|
||||
Number: number,
|
||||
Title: item.Title,
|
||||
URL: item.Link,
|
||||
Author: author,
|
||||
AuthorURL: authorURL,
|
||||
}
|
||||
|
||||
if item.PublishedParsed != nil {
|
||||
issue.CreatedAt = *item.PublishedParsed
|
||||
}
|
||||
|
||||
if item.UpdatedParsed != nil {
|
||||
issue.UpdatedAt = *item.UpdatedParsed
|
||||
} else if item.PublishedParsed != nil {
|
||||
issue.UpdatedAt = *item.PublishedParsed
|
||||
}
|
||||
|
||||
// Note: RSS doesn't include labels, so we'll have an empty labels slice
|
||||
|
||||
issues = append(issues, issue)
|
||||
}
|
||||
|
||||
// Cache the results
|
||||
r.cache.Set(cacheKey, issues)
|
||||
|
||||
return issues, nil
|
||||
}
|
||||
|
||||
// GetPullRequestsFromRSS fetches recent pull requests from GitHub RSS feed
|
||||
func (r *RSSClient) GetPullRequestsFromRSS(ctx context.Context, owner, repo string) ([]types.PullRequest, error) {
|
||||
var pullRequests []types.PullRequest
|
||||
cacheKey := fmt.Sprintf("prs_rss_%s_%s", owner, repo)
|
||||
|
||||
// Try to get from cache first
|
||||
if cachedPRs, found := r.cache.Get(cacheKey); found {
|
||||
if r.config.Verbose {
|
||||
log.Printf("Using cached pull requests from RSS for %s/%s", owner, repo)
|
||||
}
|
||||
return cachedPRs.([]types.PullRequest), nil
|
||||
}
|
||||
|
||||
if r.config.Verbose {
|
||||
log.Printf("Fetching pull requests from RSS for %s/%s", owner, repo)
|
||||
}
|
||||
|
||||
// GitHub pull requests RSS feed URL
|
||||
feedURL := fmt.Sprintf("https://github.com/%s/%s/pulls.atom", owner, repo)
|
||||
|
||||
feed, err := r.parser.ParseURLWithContext(feedURL, ctx)
|
||||
if err != nil {
|
||||
// If we can't fetch the feed, just return an empty slice
|
||||
if r.config.Verbose {
|
||||
log.Printf("Error fetching pull requests feed for %s/%s: %v", owner, repo, err)
|
||||
}
|
||||
return []types.PullRequest{}, nil
|
||||
}
|
||||
|
||||
for _, item := range feed.Items {
|
||||
// Parse the PR number from the URL
|
||||
number := 0
|
||||
parts := strings.Split(item.Link, "/pull/")
|
||||
if len(parts) > 1 {
|
||||
fmt.Sscanf(parts[1], "%d", &number)
|
||||
}
|
||||
|
||||
// Parse the author info
|
||||
author := ""
|
||||
authorURL := ""
|
||||
if item.Author != nil {
|
||||
author = item.Author.Name
|
||||
// Extract the GitHub username from the author name if possible
|
||||
if strings.Contains(author, "(") {
|
||||
parts := strings.Split(author, "(")
|
||||
if len(parts) > 1 {
|
||||
username := strings.TrimSuffix(strings.TrimPrefix(parts[1], "@"), ")")
|
||||
author = username
|
||||
authorURL = fmt.Sprintf("https://github.com/%s", username)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pr := types.PullRequest{
|
||||
Number: number,
|
||||
Title: item.Title,
|
||||
URL: item.Link,
|
||||
Author: author,
|
||||
AuthorURL: authorURL,
|
||||
Status: "open", // All items in the feed are open
|
||||
}
|
||||
|
||||
if item.PublishedParsed != nil {
|
||||
pr.CreatedAt = *item.PublishedParsed
|
||||
}
|
||||
|
||||
if item.UpdatedParsed != nil {
|
||||
pr.UpdatedAt = *item.UpdatedParsed
|
||||
} else if item.PublishedParsed != nil {
|
||||
pr.UpdatedAt = *item.PublishedParsed
|
||||
}
|
||||
|
||||
// Note: RSS doesn't include labels, so we'll have an empty labels slice
|
||||
|
||||
pullRequests = append(pullRequests, pr)
|
||||
}
|
||||
|
||||
// Cache the results
|
||||
r.cache.Set(cacheKey, pullRequests)
|
||||
|
||||
return pullRequests, nil
|
||||
}
|
219
pkg/cmd/generate.go
Normal file
219
pkg/cmd/generate.go
Normal file
@ -0,0 +1,219 @@
|
||||
// pkg/cmd/generate.go
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-i2p/go-github-dashboard/pkg/api"
|
||||
"github.com/go-i2p/go-github-dashboard/pkg/config"
|
||||
"github.com/go-i2p/go-github-dashboard/pkg/generator"
|
||||
"github.com/go-i2p/go-github-dashboard/pkg/types"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var generateCmd = &cobra.Command{
|
||||
Use: "generate",
|
||||
Short: "Generate the GitHub dashboard",
|
||||
Long: `Fetches GitHub repository data and generates a static dashboard.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runGenerate()
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(generateCmd)
|
||||
}
|
||||
|
||||
func runGenerate() error {
|
||||
// Get configuration
|
||||
cfg, err := config.GetConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error with configuration: %w", err)
|
||||
}
|
||||
|
||||
// Create a context with cancellation
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Handle interruptions gracefully
|
||||
signalChan := make(chan os.Signal, 1)
|
||||
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-signalChan
|
||||
fmt.Println("\nReceived interrupt signal, shutting down gracefully...")
|
||||
cancel()
|
||||
}()
|
||||
|
||||
// Initialize the cache
|
||||
cache := api.NewCache(cfg)
|
||||
|
||||
// Initialize clients
|
||||
githubClient := api.NewGitHubClient(cfg, cache)
|
||||
rssClient := api.NewRSSClient(cfg, cache)
|
||||
|
||||
// Initialize generators
|
||||
mdGenerator, err := generator.NewMarkdownGenerator(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating markdown generator: %w", err)
|
||||
}
|
||||
|
||||
htmlGenerator, err := generator.NewHTMLGenerator(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating HTML generator: %w", err)
|
||||
}
|
||||
|
||||
// Create dashboard data structure
|
||||
dashboard := types.Dashboard{
|
||||
Username: cfg.User,
|
||||
Organization: cfg.Organization,
|
||||
GeneratedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Fetch repositories
|
||||
fmt.Println("Fetching repositories...")
|
||||
repositories, err := githubClient.GetRepositories(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error fetching repositories: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d repositories\n", len(repositories))
|
||||
|
||||
// Create a wait group for parallel processing
|
||||
var wg sync.WaitGroup
|
||||
reposChan := make(chan types.Repository, len(repositories))
|
||||
|
||||
// Process each repository in parallel
|
||||
for i := range repositories {
|
||||
wg.Add(1)
|
||||
go func(repo types.Repository) {
|
||||
defer wg.Done()
|
||||
|
||||
// Check if context is canceled
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
owner := repo.Owner
|
||||
repoName := repo.Name
|
||||
|
||||
fmt.Printf("Processing repository: %s/%s\n", owner, repoName)
|
||||
|
||||
// Fetch pull requests from RSS first, fall back to API
|
||||
pullRequests, err := rssClient.GetPullRequestsFromRSS(ctx, owner, repoName)
|
||||
if err != nil || len(pullRequests) == 0 {
|
||||
if cfg.Verbose && err != nil {
|
||||
log.Printf("Error fetching pull requests from RSS for %s/%s: %v, falling back to API", owner, repoName, err)
|
||||
}
|
||||
pullRequests, err = githubClient.GetPullRequests(ctx, owner, repoName)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching pull requests for %s/%s: %v", owner, repoName, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch issues from RSS first, fall back to API
|
||||
issues, err := rssClient.GetIssuesFromRSS(ctx, owner, repoName)
|
||||
if err != nil || len(issues) == 0 {
|
||||
if cfg.Verbose && err != nil {
|
||||
log.Printf("Error fetching issues from RSS for %s/%s: %v, falling back to API", owner, repoName, err)
|
||||
}
|
||||
issues, err = githubClient.GetIssues(ctx, owner, repoName)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching issues for %s/%s: %v", owner, repoName, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch discussions (only available through RSS)
|
||||
discussions, err := rssClient.GetDiscussionsFromRSS(ctx, owner, repoName)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching discussions for %s/%s: %v", owner, repoName, err)
|
||||
}
|
||||
|
||||
// Update the repository with the fetched data
|
||||
repo.PullRequests = pullRequests
|
||||
repo.Issues = issues
|
||||
repo.Discussions = discussions
|
||||
|
||||
// Send the updated repository to the channel
|
||||
reposChan <- repo
|
||||
}(repositories[i])
|
||||
}
|
||||
|
||||
// Close the channel when all goroutines are done
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(reposChan)
|
||||
}()
|
||||
|
||||
// Collect results
|
||||
var processedRepos []types.Repository
|
||||
for repo := range reposChan {
|
||||
processedRepos = append(processedRepos, repo)
|
||||
}
|
||||
|
||||
// Sort repositories by name (for consistent output)
|
||||
dashboard.Repositories = processedRepos
|
||||
|
||||
// Count totals
|
||||
for _, repo := range dashboard.Repositories {
|
||||
dashboard.TotalPRs += len(repo.PullRequests)
|
||||
dashboard.TotalIssues += len(repo.Issues)
|
||||
dashboard.TotalDiscussions += len(repo.Discussions)
|
||||
}
|
||||
|
||||
// Generate markdown files
|
||||
fmt.Println("Generating markdown files...")
|
||||
markdownPaths, err := mdGenerator.GenerateAllRepositoriesMarkdown(dashboard)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error generating markdown files: %w", err)
|
||||
}
|
||||
|
||||
// Convert markdown to HTML
|
||||
fmt.Println("Converting markdown to HTML...")
|
||||
_, err = htmlGenerator.ConvertAllMarkdownToHTML(markdownPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error converting markdown to HTML: %w", err)
|
||||
}
|
||||
|
||||
// Generate the main HTML dashboard
|
||||
fmt.Println("Generating HTML dashboard...")
|
||||
err = htmlGenerator.GenerateHTML(dashboard)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error generating HTML dashboard: %w", err)
|
||||
}
|
||||
|
||||
// Create a README in the output directory
|
||||
readmePath := filepath.Join(cfg.OutputDir, "README.md")
|
||||
var targetName string
|
||||
if cfg.User != "" {
|
||||
targetName = "@" + cfg.User
|
||||
} else {
|
||||
targetName = cfg.Organization
|
||||
}
|
||||
|
||||
readme := fmt.Sprintf("# GitHub Dashboard\n\nThis dashboard was generated for %s on %s.\n\n",
|
||||
targetName,
|
||||
dashboard.GeneratedAt.Format("January 2, 2006"))
|
||||
readme += fmt.Sprintf("- Total repositories: %d\n", len(dashboard.Repositories))
|
||||
readme += fmt.Sprintf("- Total open pull requests: %d\n", dashboard.TotalPRs)
|
||||
readme += fmt.Sprintf("- Total open issues: %d\n", dashboard.TotalIssues)
|
||||
readme += fmt.Sprintf("- Total recent discussions: %d\n\n", dashboard.TotalDiscussions)
|
||||
readme += "To view the dashboard, open `index.html` in your browser.\n"
|
||||
|
||||
err = os.WriteFile(readmePath, []byte(readme), 0644)
|
||||
if err != nil {
|
||||
log.Printf("Error writing README file: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\nDashboard generated successfully in %s\n", cfg.OutputDir)
|
||||
fmt.Printf("Open %s/index.html in your browser to view the dashboard\n", cfg.OutputDir)
|
||||
|
||||
return nil
|
||||
}
|
53
pkg/cmd/main.go
Normal file
53
pkg/cmd/main.go
Normal file
@ -0,0 +1,53 @@
|
||||
// pkg/cmd/root.go
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/go-i2p/go-github-dashboard/pkg/config"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "go-github-dashboard",
|
||||
Short: "Generate a static GitHub dashboard",
|
||||
Long: `A pure Go command-line application that generates a static
|
||||
GitHub dashboard by aggregating repository data from GitHub API
|
||||
and RSS feeds, organizing content in a repository-by-repository structure.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
// The root command will just show help
|
||||
cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
// Execute executes the root command
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
cobra.OnInitialize(config.InitConfig)
|
||||
|
||||
// Persistent flags for all commands
|
||||
rootCmd.PersistentFlags().StringP("user", "u", "", "GitHub username to generate dashboard for")
|
||||
rootCmd.PersistentFlags().StringP("org", "o", "", "GitHub organization to generate dashboard for")
|
||||
rootCmd.PersistentFlags().StringP("output", "d", "./dashboard", "Output directory for the dashboard")
|
||||
rootCmd.PersistentFlags().StringP("token", "t", "", "GitHub API token (optional, increases rate limits)")
|
||||
rootCmd.PersistentFlags().String("cache-dir", "./.cache", "Directory for caching API responses")
|
||||
rootCmd.PersistentFlags().String("cache-ttl", "1h", "Cache time-to-live duration (e.g., 1h, 30m)")
|
||||
rootCmd.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose output")
|
||||
|
||||
// Bind flags to viper
|
||||
viper.BindPFlag("user", rootCmd.PersistentFlags().Lookup("user"))
|
||||
viper.BindPFlag("org", rootCmd.PersistentFlags().Lookup("org"))
|
||||
viper.BindPFlag("output", rootCmd.PersistentFlags().Lookup("output"))
|
||||
viper.BindPFlag("token", rootCmd.PersistentFlags().Lookup("token"))
|
||||
viper.BindPFlag("cache-dir", rootCmd.PersistentFlags().Lookup("cache-dir"))
|
||||
viper.BindPFlag("cache-ttl", rootCmd.PersistentFlags().Lookup("cache-ttl"))
|
||||
viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose"))
|
||||
}
|
30
pkg/cmd/version.go
Normal file
30
pkg/cmd/version.go
Normal file
@ -0,0 +1,30 @@
|
||||
// pkg/cmd/version.go
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Version information
|
||||
var (
|
||||
Version = "0.1.0"
|
||||
BuildDate = "unknown"
|
||||
Commit = "unknown"
|
||||
)
|
||||
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print the version number",
|
||||
Long: `Print the version, build date, and commit hash.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Printf("go-github-dashboard version %s\n", Version)
|
||||
fmt.Printf("Build date: %s\n", BuildDate)
|
||||
fmt.Printf("Commit: %s\n", Commit)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
}
|
76
pkg/config/config.go
Normal file
76
pkg/config/config.go
Normal file
@ -0,0 +1,76 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/go-i2p/go-github-dashboard/pkg/types"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// InitConfig initializes the Viper configuration
|
||||
func InitConfig() {
|
||||
// Set default values
|
||||
viper.SetDefault("output", "./dashboard")
|
||||
viper.SetDefault("cache-dir", "./.cache")
|
||||
viper.SetDefault("cache-ttl", "1h")
|
||||
viper.SetDefault("verbose", false)
|
||||
|
||||
// Environment variables
|
||||
viper.SetEnvPrefix("GITHUB_DASHBOARD") // will convert to GITHUB_DASHBOARD_*
|
||||
viper.AutomaticEnv()
|
||||
|
||||
// Check for token in environment
|
||||
if token := os.Getenv("GITHUB_TOKEN"); token != "" && viper.GetString("token") == "" {
|
||||
viper.Set("token", token)
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfig builds and validates the configuration from Viper
|
||||
func GetConfig() (*types.Config, error) {
|
||||
cacheTTL, err := time.ParseDuration(viper.GetString("cache-ttl"))
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid cache-ttl format: use a valid duration string (e.g., 1h, 30m)")
|
||||
}
|
||||
|
||||
config := &types.Config{
|
||||
User: viper.GetString("user"),
|
||||
Organization: viper.GetString("org"),
|
||||
OutputDir: viper.GetString("output"),
|
||||
GithubToken: viper.GetString("token"),
|
||||
CacheDir: viper.GetString("cache-dir"),
|
||||
CacheTTL: cacheTTL,
|
||||
Verbose: viper.GetBool("verbose"),
|
||||
}
|
||||
|
||||
// Validate config
|
||||
if config.User == "" && config.Organization == "" {
|
||||
return nil, errors.New("either user or organization must be specified")
|
||||
}
|
||||
|
||||
if config.User != "" && config.Organization != "" {
|
||||
return nil, errors.New("only one of user or organization can be specified")
|
||||
}
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
err = os.MkdirAll(config.OutputDir, 0755)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create repositories directory
|
||||
err = os.MkdirAll(filepath.Join(config.OutputDir, "repositories"), 0755)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create cache directory if it doesn't exist
|
||||
err = os.MkdirAll(config.CacheDir, 0755)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
558
pkg/generator/html.go
Normal file
558
pkg/generator/html.go
Normal file
@ -0,0 +1,558 @@
|
||||
package generator
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-i2p/go-github-dashboard/pkg/types"
|
||||
"github.com/russross/blackfriday/v2"
|
||||
)
|
||||
|
||||
// HTMLGenerator handles the generation of HTML files
|
||||
type HTMLGenerator struct {
|
||||
outputDir string
|
||||
template *template.Template
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewHTMLGenerator creates a new HTMLGenerator
|
||||
func NewHTMLGenerator(config *types.Config) (*HTMLGenerator, error) {
|
||||
// Create the template
|
||||
indexTmpl := `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GitHub Dashboard {{if .Username}}for @{{.Username}}{{else}}for {{.Organization}}{{end}}</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>GitHub Dashboard {{if .Username}}for <a href="https://github.com/{{.Username}}">@{{.Username}}</a>{{else}}for <a href="https://github.com/{{.Organization}}">{{.Organization}}</a>{{end}}</h1>
|
||||
<div class="dashboard-stats">
|
||||
<span>{{len .Repositories}} repositories</span>
|
||||
<span>{{.TotalPRs}} open pull requests</span>
|
||||
<span>{{.TotalIssues}} open issues</span>
|
||||
<span>{{.TotalDiscussions}} recent discussions</span>
|
||||
</div>
|
||||
<p class="generated-at">Generated on {{.GeneratedAt.Format "January 2, 2006 at 15:04"}}</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="repositories">
|
||||
<h2>Repositories</h2>
|
||||
|
||||
{{range .Repositories}}
|
||||
<div class="repository">
|
||||
<div class="collapsible">
|
||||
<input type="checkbox" id="repo-{{.Name}}" class="toggle">
|
||||
<label for="repo-{{.Name}}" class="toggle-label">
|
||||
<span class="repo-name">{{.Name}}</span>
|
||||
<div class="repo-stats">
|
||||
<span class="stat">{{len .PullRequests}} PRs</span>
|
||||
<span class="stat">{{len .Issues}} issues</span>
|
||||
<span class="stat">{{len .Discussions}} discussions</span>
|
||||
</div>
|
||||
</label>
|
||||
<div class="collapsible-content">
|
||||
<div class="repo-details">
|
||||
<p class="repo-description">{{if .Description}}{{.Description}}{{else}}No description provided.{{end}}</p>
|
||||
<div class="repo-meta">
|
||||
<a href="{{.URL}}" target="_blank">View on GitHub</a>
|
||||
<span>⭐ {{.Stars}}</span>
|
||||
<span>🍴 {{.Forks}}</span>
|
||||
<span>Updated: {{.LastUpdated.Format "2006-01-02"}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .PullRequests}}
|
||||
<div class="collapsible">
|
||||
<input type="checkbox" id="prs-{{.Name}}" class="toggle">
|
||||
<label for="prs-{{.Name}}" class="toggle-label section-label pr-label">
|
||||
Open Pull Requests ({{len .PullRequests}})
|
||||
</label>
|
||||
<div class="collapsible-content">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Author</th>
|
||||
<th>Updated</th>
|
||||
<th>Labels</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .PullRequests}}
|
||||
<tr>
|
||||
<td><a href="{{.URL}}" target="_blank">{{.Title}}</a></td>
|
||||
<td><a href="{{.AuthorURL}}" target="_blank">@{{.Author}}</a></td>
|
||||
<td>{{.UpdatedAt.Format "2006-01-02"}}</td>
|
||||
<td>{{range $i, $label := .Labels}}{{if $i}}, {{end}}{{$label.Name}}{{else}}<em>none</em>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Issues}}
|
||||
<div class="collapsible">
|
||||
<input type="checkbox" id="issues-{{.Name}}" class="toggle">
|
||||
<label for="issues-{{.Name}}" class="toggle-label section-label issue-label">
|
||||
Open Issues ({{len .Issues}})
|
||||
</label>
|
||||
<div class="collapsible-content">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Author</th>
|
||||
<th>Updated</th>
|
||||
<th>Labels</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Issues}}
|
||||
<tr>
|
||||
<td><a href="{{.URL}}" target="_blank">{{.Title}}</a></td>
|
||||
<td><a href="{{.AuthorURL}}" target="_blank">@{{.Author}}</a></td>
|
||||
<td>{{.UpdatedAt.Format "2006-01-02"}}</td>
|
||||
<td>{{range $i, $label := .Labels}}{{if $i}}, {{end}}{{$label.Name}}{{else}}<em>none</em>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Discussions}}
|
||||
<div class="collapsible">
|
||||
<input type="checkbox" id="discussions-{{.Name}}" class="toggle">
|
||||
<label for="discussions-{{.Name}}" class="toggle-label section-label discussion-label">
|
||||
Recent Discussions ({{len .Discussions}})
|
||||
</label>
|
||||
<div class="collapsible-content">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Started By</th>
|
||||
<th>Last Activity</th>
|
||||
<th>Category</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Discussions}}
|
||||
<tr>
|
||||
<td><a href="{{.URL}}" target="_blank">{{.Title}}</a></td>
|
||||
<td><a href="{{.AuthorURL}}" target="_blank">@{{.Author}}</a></td>
|
||||
<td>{{.LastUpdated.Format "2006-01-02"}}</td>
|
||||
<td>{{.Category}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="repo-links">
|
||||
<a href="repositories/{{.Name}}.md">View as Markdown</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>Generated with <a href="https://github.com/yourusername/go-github-dashboard">go-github-dashboard</a></p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
tmpl, err := template.New("index.html").Parse(indexTmpl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing HTML template: %w", err)
|
||||
}
|
||||
|
||||
return &HTMLGenerator{
|
||||
outputDir: config.OutputDir,
|
||||
template: tmpl,
|
||||
verbose: config.Verbose,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateCSS generates the CSS file for the dashboard
|
||||
func (g *HTMLGenerator) GenerateCSS() error {
|
||||
css := `/* Base styles */
|
||||
:root {
|
||||
--primary-color: #0366d6;
|
||||
--secondary-color: #586069;
|
||||
--background-color: #ffffff;
|
||||
--border-color: #e1e4e8;
|
||||
--pr-color: #28a745;
|
||||
--issue-color: #d73a49;
|
||||
--discussion-color: #6f42c1;
|
||||
--hover-color: #f6f8fa;
|
||||
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-family);
|
||||
line-height: 1.5;
|
||||
color: #24292e;
|
||||
background-color: var(--background-color);
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Header styles */
|
||||
header {
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.dashboard-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.dashboard-stats span {
|
||||
background-color: #f1f8ff;
|
||||
border-radius: 20px;
|
||||
padding: 5px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.generated-at {
|
||||
font-size: 14px;
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
/* Repository styles */
|
||||
.repositories {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.repositories h2 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.repository {
|
||||
margin-bottom: 15px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.repo-details {
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.repo-description {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.repo-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
font-size: 14px;
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.repo-links {
|
||||
padding: 10px 15px;
|
||||
font-size: 14px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Collapsible sections */
|
||||
.collapsible {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background-color: #f6f8fa;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
border-top: 1px solid var(--border-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pr-label {
|
||||
color: var(--pr-color);
|
||||
}
|
||||
|
||||
.issue-label {
|
||||
color: var(--issue-color);
|
||||
}
|
||||
|
||||
.discussion-label {
|
||||
color: var(--discussion-color);
|
||||
}
|
||||
|
||||
.toggle-label::after {
|
||||
content: '+';
|
||||
font-size: 18px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.toggle:checked ~ .toggle-label::after {
|
||||
content: '−';
|
||||
}
|
||||
|
||||
.collapsible-content {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.35s ease;
|
||||
}
|
||||
|
||||
.toggle:checked ~ .collapsible-content {
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
/* Table styles */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 8px 15px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background-color: #f6f8fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.data-table tr:hover {
|
||||
background-color: var(--hover-color);
|
||||
}
|
||||
|
||||
/* Links */
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Repository name and stats */
|
||||
.repo-name {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.repo-stats {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
background-color: #f1f8ff;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
margin-top: 40px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
font-size: 14px;
|
||||
color: var(--secondary-color);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.toggle-label {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.repo-stats {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.dashboard-stats {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
}
|
||||
}`
|
||||
|
||||
err := os.WriteFile(filepath.Join(g.outputDir, "style.css"), []byte(css), 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error writing CSS file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateHTML generates the main HTML dashboard
|
||||
func (g *HTMLGenerator) GenerateHTML(dashboard types.Dashboard) error {
|
||||
if g.verbose {
|
||||
log.Println("Generating HTML dashboard")
|
||||
}
|
||||
|
||||
// Render the template
|
||||
var buf bytes.Buffer
|
||||
err := g.template.Execute(&buf, dashboard)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error executing template: %w", err)
|
||||
}
|
||||
|
||||
// Write the file
|
||||
outputPath := filepath.Join(g.outputDir, "index.html")
|
||||
err = os.WriteFile(outputPath, buf.Bytes(), 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error writing HTML file: %w", err)
|
||||
}
|
||||
|
||||
// Generate the CSS file
|
||||
err = g.GenerateCSS()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConvertMarkdownToHTML converts a markdown file to HTML
|
||||
func (g *HTMLGenerator) ConvertMarkdownToHTML(markdownPath string) (string, error) {
|
||||
if g.verbose {
|
||||
log.Printf("Converting markdown to HTML: %s", markdownPath)
|
||||
}
|
||||
|
||||
// Read the markdown file
|
||||
markdownContent, err := os.ReadFile(markdownPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error reading markdown file: %w", err)
|
||||
}
|
||||
|
||||
// Convert the markdown to HTML
|
||||
htmlContent := blackfriday.Run(markdownContent)
|
||||
|
||||
// Determine the output filename
|
||||
baseName := filepath.Base(markdownPath)
|
||||
htmlFileName := strings.TrimSuffix(baseName, filepath.Ext(baseName)) + ".html"
|
||||
htmlPath := filepath.Join(g.outputDir, "repositories", htmlFileName)
|
||||
|
||||
// Create a simple HTML wrapper
|
||||
htmlPage := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>%s</title>
|
||||
<link rel="stylesheet" href="../style.css">
|
||||
<style>
|
||||
.markdown-body {
|
||||
padding: 20px;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.markdown-body table {
|
||||
width: 100%%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.markdown-body th, .markdown-body td {
|
||||
padding: 8px 15px;
|
||||
text-align: left;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.markdown-body th {
|
||||
background-color: #f6f8fa;
|
||||
}
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a href="../index.html" class="back-link">← Back to Dashboard</a>
|
||||
<div class="markdown-body">
|
||||
%s
|
||||
</div>
|
||||
</body>
|
||||
</html>`, strings.TrimSuffix(baseName, filepath.Ext(baseName)), string(htmlContent))
|
||||
|
||||
// Write the HTML file
|
||||
err = os.WriteFile(htmlPath, []byte(htmlPage), 0644)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error writing HTML file: %w", err)
|
||||
}
|
||||
|
||||
return htmlPath, nil
|
||||
}
|
||||
|
||||
// ConvertAllMarkdownToHTML converts all markdown files to HTML
|
||||
func (g *HTMLGenerator) ConvertAllMarkdownToHTML(markdownPaths []string) ([]string, error) {
|
||||
var htmlPaths []string
|
||||
|
||||
for _, markdownPath := range markdownPaths {
|
||||
htmlPath, err := g.ConvertMarkdownToHTML(markdownPath)
|
||||
if err != nil {
|
||||
return htmlPaths, err
|
||||
}
|
||||
htmlPaths = append(htmlPaths, htmlPath)
|
||||
}
|
||||
|
||||
return htmlPaths, nil
|
||||
}
|
121
pkg/generator/markdown.go
Normal file
121
pkg/generator/markdown.go
Normal file
@ -0,0 +1,121 @@
|
||||
package generator
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/go-i2p/go-github-dashboard/pkg/types"
|
||||
)
|
||||
|
||||
// MarkdownGenerator handles the generation of markdown files
|
||||
type MarkdownGenerator struct {
|
||||
outputDir string
|
||||
template *template.Template
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewMarkdownGenerator creates a new MarkdownGenerator
|
||||
func NewMarkdownGenerator(config *types.Config) (*MarkdownGenerator, error) {
|
||||
// Create the template
|
||||
tmpl, err := template.New("repository.md.tmpl").Parse(`# Repository: {{.Name}}
|
||||
|
||||
{{if .Description}}{{.Description}}{{else}}No description provided.{{end}}
|
||||
|
||||
## Open Pull Requests
|
||||
|
||||
{{if .PullRequests}}
|
||||
| Title | Author | Updated | Labels |
|
||||
|-------|--------|---------|--------|
|
||||
{{range .PullRequests}}| [{{.Title}}]({{.URL}}) | [{{.Author}}]({{.AuthorURL}}) | {{.UpdatedAt.Format "2006-01-02"}} | {{range $i, $label := .Labels}}{{if $i}}, {{end}}{{$label.Name}}{{else}}*none*{{end}} |
|
||||
{{end}}
|
||||
{{else}}
|
||||
*No open pull requests*
|
||||
{{end}}
|
||||
|
||||
## Open Issues
|
||||
|
||||
{{if .Issues}}
|
||||
| Title | Author | Updated | Labels |
|
||||
|-------|--------|---------|--------|
|
||||
{{range .Issues}}| [{{.Title}}]({{.URL}}) | [{{.Author}}]({{.AuthorURL}}) | {{.UpdatedAt.Format "2006-01-02"}} | {{range $i, $label := .Labels}}{{if $i}}, {{end}}{{$label.Name}}{{else}}*none*{{end}} |
|
||||
{{end}}
|
||||
{{else}}
|
||||
*No open issues*
|
||||
{{end}}
|
||||
|
||||
## Recent Discussions
|
||||
|
||||
{{if .Discussions}}
|
||||
| Title | Started By | Last Activity | Category |
|
||||
|-------|------------|---------------|----------|
|
||||
{{range .Discussions}}| [{{.Title}}]({{.URL}}) | [{{.Author}}]({{.AuthorURL}}) | {{.LastUpdated.Format "2006-01-02"}} | {{.Category}} |
|
||||
{{end}}
|
||||
{{else}}
|
||||
*No recent discussions*
|
||||
{{end}}
|
||||
|
||||
---
|
||||
*Generated at {{.GeneratedAt.Format "2006-01-02 15:04:05"}}*
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing markdown template: %w", err)
|
||||
}
|
||||
|
||||
return &MarkdownGenerator{
|
||||
outputDir: config.OutputDir,
|
||||
template: tmpl,
|
||||
verbose: config.Verbose,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateRepositoryMarkdown generates a markdown file for a repository
|
||||
func (g *MarkdownGenerator) GenerateRepositoryMarkdown(repo types.Repository) (string, error) {
|
||||
if g.verbose {
|
||||
log.Printf("Generating markdown for repository %s", repo.FullName)
|
||||
}
|
||||
|
||||
// Prepare the template data
|
||||
data := struct {
|
||||
types.Repository
|
||||
GeneratedAt time.Time
|
||||
}{
|
||||
Repository: repo,
|
||||
GeneratedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Render the template
|
||||
var buf bytes.Buffer
|
||||
err := g.template.Execute(&buf, data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error executing template: %w", err)
|
||||
}
|
||||
|
||||
// Write the file
|
||||
outputPath := filepath.Join(g.outputDir, "repositories", fmt.Sprintf("%s.md", repo.Name))
|
||||
err = os.WriteFile(outputPath, buf.Bytes(), 0644)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error writing markdown file: %w", err)
|
||||
}
|
||||
|
||||
return outputPath, nil
|
||||
}
|
||||
|
||||
// GenerateAllRepositoriesMarkdown generates markdown files for all repositories
|
||||
func (g *MarkdownGenerator) GenerateAllRepositoriesMarkdown(dashboard types.Dashboard) ([]string, error) {
|
||||
var filePaths []string
|
||||
|
||||
for _, repo := range dashboard.Repositories {
|
||||
path, err := g.GenerateRepositoryMarkdown(repo)
|
||||
if err != nil {
|
||||
return filePaths, err
|
||||
}
|
||||
filePaths = append(filePaths, path)
|
||||
}
|
||||
|
||||
return filePaths, nil
|
||||
}
|
Reference in New Issue
Block a user