Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Package cmd is a package that contains the root command (entrypoint) for the GitHub Skyline CLI tool.
package cmd

import (
"context"
"fmt"
"os"
"time"

"github.com/cli/go-gh/v2/pkg/auth"
"github.com/cli/go-gh/v2/pkg/browser"
"github.com/github/gh-skyline/cmd/skyline"
"github.com/github/gh-skyline/internal/errors"
"github.com/github/gh-skyline/internal/github"
"github.com/github/gh-skyline/internal/logger"
"github.com/github/gh-skyline/internal/utils"
"github.com/spf13/cobra"
)

// Command line variables and root command configuration
var (
yearRange string
user string
full bool
debug bool
web bool
artOnly bool
output string // new output path flag
)

// rootCmd is the root command for the GitHub Skyline CLI tool.
var rootCmd = &cobra.Command{
Use: "skyline",
Short: "Generate a 3D model of a user's GitHub contribution history",
Long: `GitHub Skyline creates 3D printable STL files from GitHub contribution data.
It can generate models for specific years or year ranges for the authenticated user or an optional specified user.

While the STL file is being generated, an ASCII preview will be displayed in the terminal.

ASCII Preview Legend:
' ' Empty/Sky - No contributions
'.' Future dates - What contributions could you make?
'░' Low level - Light contribution activity
'▒' Medium level - Moderate contribution activity
'▓' High level - Heavy contribution activity
'╻┃╽' Top level - Last block with contributions in the week (Low, Medium, High)

Layout:
Each column represents one week. Days within each week are reordered vertically
to create a "building" effect, with empty spaces (no contributions) at the top.`,
RunE: handleSkylineCommand,
}

// init initializes command line flags for the skyline CLI tool.
func init() {
initFlags()
}

// Execute initializes and executes the root command for the GitHub Skyline CLI.
func Execute(_ context.Context) error {
if err := rootCmd.Execute(); err != nil {
return err
}
return nil
}

// initFlags sets up command line flags for the skyline CLI tool.
func initFlags() {
flags := rootCmd.Flags()
flags.StringVarP(&yearRange, "year", "y", fmt.Sprintf("%d", time.Now().Year()), "Year or year range (e.g., 2024 or 2014-2024)")
flags.StringVarP(&user, "user", "u", "", "GitHub username (optional, defaults to authenticated user)")
flags.BoolVarP(&full, "full", "f", false, "Generate contribution graph from join year to current year")
flags.BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
flags.BoolVarP(&web, "web", "w", false, "Open GitHub profile (authenticated or specified user).")
flags.BoolVarP(&artOnly, "art-only", "a", false, "Generate only ASCII preview")
flags.StringVarP(&output, "output", "o", "", "Output file path (optional)")
}

// executeRootCmd is the main execution function for the root command.
func handleSkylineCommand(_ *cobra.Command, _ []string) error {
log := logger.GetLogger()
if debug {
log.SetLevel(logger.DEBUG)
if err := log.Debug("Debug logging enabled"); err != nil {
return err
}
}

client, err := github.InitializeGitHubClient()
if err != nil {
return errors.New(errors.NetworkError, "failed to initialize GitHub client", err)
}

if web {
b := browser.New("", os.Stdout, os.Stderr)
if err := openGitHubProfile(user, client, b); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
return nil
}

startYear, endYear, err := utils.ParseYearRange(yearRange)
if err != nil {
return fmt.Errorf("invalid year range: %v", err)
}

return skyline.GenerateSkyline(startYear, endYear, user, full, output, artOnly)
}

// Browser interface matches browser.Browser functionality.
type Browser interface {
Browse(url string) error
}

// openGitHubProfile opens the GitHub profile page for the specified user or authenticated user.
func openGitHubProfile(targetUser string, client skyline.GitHubClientInterface, b Browser) error {
if targetUser == "" {
username, err := client.GetAuthenticatedUser()
if err != nil {
return errors.New(errors.NetworkError, "failed to get authenticated user", err)
}
targetUser = username
}

hostname, _ := auth.DefaultHost()
profileURL := fmt.Sprintf("https://%s/%s", hostname, targetUser)
return b.Browse(profileURL)
}
98 changes: 98 additions & 0 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package cmd

import (
"fmt"
"testing"

"github.com/github/gh-skyline/internal/testutil/mocks"
)

// MockBrowser implements the Browser interface
type MockBrowser struct {
LastURL string
Err error
}

// Browse implements the Browser interface
func (m *MockBrowser) Browse(url string) error {
m.LastURL = url
return m.Err
}

func TestRootCmd(t *testing.T) {
cmd := rootCmd
if cmd.Use != "skyline" {
t.Errorf("expected command use to be 'skyline', got %s", cmd.Use)
}
if cmd.Short != "Generate a 3D model of a user's GitHub contribution history" {
t.Errorf("expected command short description to be 'Generate a 3D model of a user's GitHub contribution history', got %s", cmd.Short)
}
if cmd.Long == "" {
t.Error("expected command long description to be non-empty")
}
}

func TestInit(t *testing.T) {
flags := rootCmd.Flags()
expectedFlags := []string{"year", "user", "full", "debug", "web", "art-only", "output"}
for _, flag := range expectedFlags {
if flags.Lookup(flag) == nil {
t.Errorf("expected flag %s to be initialized", flag)
}
}
}

// TestOpenGitHubProfile tests the openGitHubProfile function
func TestOpenGitHubProfile(t *testing.T) {
tests := []struct {
name string
targetUser string
mockClient *mocks.MockGitHubClient
wantURL string
wantErr bool
}{
{
name: "specific user",
targetUser: "testuser",
mockClient: &mocks.MockGitHubClient{},
wantURL: "https://github.com/testuser",
wantErr: false,
},
{
name: "authenticated user",
targetUser: "",
mockClient: &mocks.MockGitHubClient{
Username: "authuser",
},
wantURL: "https://github.com/authuser",
wantErr: false,
},
{
name: "client error",
targetUser: "",
mockClient: &mocks.MockGitHubClient{
Err: fmt.Errorf("mock error"),
},
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockBrowser := &MockBrowser{}
if tt.wantErr {
mockBrowser.Err = fmt.Errorf("mock error")
}
err := openGitHubProfile(tt.targetUser, tt.mockClient, mockBrowser)

if (err != nil) != tt.wantErr {
t.Errorf("openGitHubProfile() error = %v, wantErr %v", err, tt.wantErr)
return
}

if !tt.wantErr && mockBrowser.LastURL != tt.wantURL {
t.Errorf("openGitHubProfile() URL = %v, want %v", mockBrowser.LastURL, tt.wantURL)
}
})
}
}
122 changes: 122 additions & 0 deletions cmd/skyline/skyline.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Package skyline provides the entry point for the GitHub Skyline Generator.
// It generates a 3D model of GitHub contributions in STL format.
package skyline

import (
"fmt"
"strings"
"time"

"github.com/github/gh-skyline/internal/ascii"
"github.com/github/gh-skyline/internal/errors"
"github.com/github/gh-skyline/internal/github"
"github.com/github/gh-skyline/internal/logger"
"github.com/github/gh-skyline/internal/stl"
"github.com/github/gh-skyline/internal/types"
"github.com/github/gh-skyline/internal/utils"
)

// GitHubClientInterface defines the methods for interacting with GitHub API
type GitHubClientInterface interface {
GetAuthenticatedUser() (string, error)
GetUserJoinYear(username string) (int, error)
FetchContributions(username string, year int) (*types.ContributionsResponse, error)
}

// GenerateSkyline creates a 3D model with ASCII art preview of GitHub contributions for the specified year range, or "full lifetime" of the user
func GenerateSkyline(startYear, endYear int, targetUser string, full bool, output string, artOnly bool) error {
log := logger.GetLogger()

client, err := github.InitializeGitHubClient()
if err != nil {
return errors.New(errors.NetworkError, "failed to initialize GitHub client", err)
}

if targetUser == "" {
if err := log.Debug("No target user specified, using authenticated user"); err != nil {
return err
}
username, err := client.GetAuthenticatedUser()
if err != nil {
return errors.New(errors.NetworkError, "failed to get authenticated user", err)
}
targetUser = username
}

if full {
joinYear, err := client.GetUserJoinYear(targetUser)
if err != nil {
return errors.New(errors.NetworkError, "failed to get user join year", err)
}
startYear = joinYear
endYear = time.Now().Year()
}

var allContributions [][][]types.ContributionDay
for year := startYear; year <= endYear; year++ {
contributions, err := fetchContributionData(client, targetUser, year)
if err != nil {
return err
}
allContributions = append(allContributions, contributions)

// Generate ASCII art for each year
asciiArt, err := ascii.GenerateASCII(contributions, targetUser, year, (year == startYear) && !artOnly, !artOnly)
if err != nil {
if warnErr := log.Warning("Failed to generate ASCII preview: %v", err); warnErr != nil {
return warnErr
}
} else {
if year == startYear {
// For first year, show full ASCII art including header
fmt.Println(asciiArt)
} else {
// For subsequent years, skip the header
lines := strings.Split(asciiArt, "\n")
gridStart := 0
for i, line := range lines {
containsEmptyBlock := strings.Contains(line, string(ascii.EmptyBlock))
containsFoundationLow := strings.Contains(line, string(ascii.FoundationLow))
isNotOnlyEmptyBlocks := strings.Trim(line, string(ascii.EmptyBlock)) != ""

if (containsEmptyBlock || containsFoundationLow) && isNotOnlyEmptyBlocks {
gridStart = i
break
}
}
// Print just the grid and user info
fmt.Println(strings.Join(lines[gridStart:], "\n"))
}
}
}

if !artOnly {
// Generate filename
outputPath := utils.GenerateOutputFilename(targetUser, startYear, endYear, output)

// Generate the STL file
if len(allContributions) == 1 {
return stl.GenerateSTL(allContributions[0], outputPath, targetUser, startYear)
}
return stl.GenerateSTLRange(allContributions, outputPath, targetUser, startYear, endYear)
}

return nil
}

// fetchContributionData retrieves and formats the contribution data for the specified year.
func fetchContributionData(client *github.Client, username string, year int) ([][]types.ContributionDay, error) {
response, err := client.FetchContributions(username, year)
if err != nil {
return nil, fmt.Errorf("failed to fetch contributions: %w", err)
}

// Convert weeks data to 2D array for STL generation
weeks := response.User.ContributionsCollection.ContributionCalendar.Weeks
contributionGrid := make([][]types.ContributionDay, len(weeks))
for i, week := range weeks {
contributionGrid[i] = week.ContributionDays
}

return contributionGrid, nil
}
Loading