Skip to content

Commit 69eff01

Browse files
Merge branch 'main' into dependabot/go_modules/go-dependencies-e99a8677a1
2 parents c5f4e6f + e6d1541 commit 69eff01

42 files changed

Lines changed: 745 additions & 628 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cmd/root.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Package cmd is a package that contains the root command (entrypoint) for the GitHub Skyline CLI tool.
2+
package cmd
3+
4+
import (
5+
"context"
6+
"fmt"
7+
"os"
8+
"time"
9+
10+
"github.com/cli/go-gh/v2/pkg/auth"
11+
"github.com/cli/go-gh/v2/pkg/browser"
12+
"github.com/github/gh-skyline/cmd/skyline"
13+
"github.com/github/gh-skyline/internal/errors"
14+
"github.com/github/gh-skyline/internal/github"
15+
"github.com/github/gh-skyline/internal/logger"
16+
"github.com/github/gh-skyline/internal/utils"
17+
"github.com/spf13/cobra"
18+
)
19+
20+
// Command line variables and root command configuration
21+
var (
22+
yearRange string
23+
user string
24+
full bool
25+
debug bool
26+
web bool
27+
artOnly bool
28+
output string // new output path flag
29+
)
30+
31+
// rootCmd is the root command for the GitHub Skyline CLI tool.
32+
var rootCmd = &cobra.Command{
33+
Use: "skyline",
34+
Short: "Generate a 3D model of a user's GitHub contribution history",
35+
Long: `GitHub Skyline creates 3D printable STL files from GitHub contribution data.
36+
It can generate models for specific years or year ranges for the authenticated user or an optional specified user.
37+
38+
While the STL file is being generated, an ASCII preview will be displayed in the terminal.
39+
40+
ASCII Preview Legend:
41+
' ' Empty/Sky - No contributions
42+
'.' Future dates - What contributions could you make?
43+
'░' Low level - Light contribution activity
44+
'▒' Medium level - Moderate contribution activity
45+
'▓' High level - Heavy contribution activity
46+
'╻┃╽' Top level - Last block with contributions in the week (Low, Medium, High)
47+
48+
Layout:
49+
Each column represents one week. Days within each week are reordered vertically
50+
to create a "building" effect, with empty spaces (no contributions) at the top.`,
51+
RunE: handleSkylineCommand,
52+
}
53+
54+
// init initializes command line flags for the skyline CLI tool.
55+
func init() {
56+
initFlags()
57+
}
58+
59+
// Execute initializes and executes the root command for the GitHub Skyline CLI.
60+
func Execute(_ context.Context) error {
61+
if err := rootCmd.Execute(); err != nil {
62+
return err
63+
}
64+
return nil
65+
}
66+
67+
// initFlags sets up command line flags for the skyline CLI tool.
68+
func initFlags() {
69+
flags := rootCmd.Flags()
70+
flags.StringVarP(&yearRange, "year", "y", fmt.Sprintf("%d", time.Now().Year()), "Year or year range (e.g., 2024 or 2014-2024)")
71+
flags.StringVarP(&user, "user", "u", "", "GitHub username (optional, defaults to authenticated user)")
72+
flags.BoolVarP(&full, "full", "f", false, "Generate contribution graph from join year to current year")
73+
flags.BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
74+
flags.BoolVarP(&web, "web", "w", false, "Open GitHub profile (authenticated or specified user).")
75+
flags.BoolVarP(&artOnly, "art-only", "a", false, "Generate only ASCII preview")
76+
flags.StringVarP(&output, "output", "o", "", "Output file path (optional)")
77+
}
78+
79+
// executeRootCmd is the main execution function for the root command.
80+
func handleSkylineCommand(_ *cobra.Command, _ []string) error {
81+
log := logger.GetLogger()
82+
if debug {
83+
log.SetLevel(logger.DEBUG)
84+
if err := log.Debug("Debug logging enabled"); err != nil {
85+
return err
86+
}
87+
}
88+
89+
client, err := github.InitializeGitHubClient()
90+
if err != nil {
91+
return errors.New(errors.NetworkError, "failed to initialize GitHub client", err)
92+
}
93+
94+
if web {
95+
b := browser.New("", os.Stdout, os.Stderr)
96+
if err := openGitHubProfile(user, client, b); err != nil {
97+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
98+
os.Exit(1)
99+
}
100+
return nil
101+
}
102+
103+
startYear, endYear, err := utils.ParseYearRange(yearRange)
104+
if err != nil {
105+
return fmt.Errorf("invalid year range: %v", err)
106+
}
107+
108+
return skyline.GenerateSkyline(startYear, endYear, user, full, output, artOnly)
109+
}
110+
111+
// Browser interface matches browser.Browser functionality.
112+
type Browser interface {
113+
Browse(url string) error
114+
}
115+
116+
// openGitHubProfile opens the GitHub profile page for the specified user or authenticated user.
117+
func openGitHubProfile(targetUser string, client skyline.GitHubClientInterface, b Browser) error {
118+
if targetUser == "" {
119+
username, err := client.GetAuthenticatedUser()
120+
if err != nil {
121+
return errors.New(errors.NetworkError, "failed to get authenticated user", err)
122+
}
123+
targetUser = username
124+
}
125+
126+
hostname, _ := auth.DefaultHost()
127+
profileURL := fmt.Sprintf("https://%s/%s", hostname, targetUser)
128+
return b.Browse(profileURL)
129+
}

cmd/root_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"github.com/github/gh-skyline/internal/testutil/mocks"
8+
)
9+
10+
// MockBrowser implements the Browser interface
11+
type MockBrowser struct {
12+
LastURL string
13+
Err error
14+
}
15+
16+
// Browse implements the Browser interface
17+
func (m *MockBrowser) Browse(url string) error {
18+
m.LastURL = url
19+
return m.Err
20+
}
21+
22+
func TestRootCmd(t *testing.T) {
23+
cmd := rootCmd
24+
if cmd.Use != "skyline" {
25+
t.Errorf("expected command use to be 'skyline', got %s", cmd.Use)
26+
}
27+
if cmd.Short != "Generate a 3D model of a user's GitHub contribution history" {
28+
t.Errorf("expected command short description to be 'Generate a 3D model of a user's GitHub contribution history', got %s", cmd.Short)
29+
}
30+
if cmd.Long == "" {
31+
t.Error("expected command long description to be non-empty")
32+
}
33+
}
34+
35+
func TestInit(t *testing.T) {
36+
flags := rootCmd.Flags()
37+
expectedFlags := []string{"year", "user", "full", "debug", "web", "art-only", "output"}
38+
for _, flag := range expectedFlags {
39+
if flags.Lookup(flag) == nil {
40+
t.Errorf("expected flag %s to be initialized", flag)
41+
}
42+
}
43+
}
44+
45+
// TestOpenGitHubProfile tests the openGitHubProfile function
46+
func TestOpenGitHubProfile(t *testing.T) {
47+
tests := []struct {
48+
name string
49+
targetUser string
50+
mockClient *mocks.MockGitHubClient
51+
wantURL string
52+
wantErr bool
53+
}{
54+
{
55+
name: "specific user",
56+
targetUser: "testuser",
57+
mockClient: &mocks.MockGitHubClient{},
58+
wantURL: "https://github.com/testuser",
59+
wantErr: false,
60+
},
61+
{
62+
name: "authenticated user",
63+
targetUser: "",
64+
mockClient: &mocks.MockGitHubClient{
65+
Username: "authuser",
66+
},
67+
wantURL: "https://github.com/authuser",
68+
wantErr: false,
69+
},
70+
{
71+
name: "client error",
72+
targetUser: "",
73+
mockClient: &mocks.MockGitHubClient{
74+
Err: fmt.Errorf("mock error"),
75+
},
76+
wantErr: true,
77+
},
78+
}
79+
80+
for _, tt := range tests {
81+
t.Run(tt.name, func(t *testing.T) {
82+
mockBrowser := &MockBrowser{}
83+
if tt.wantErr {
84+
mockBrowser.Err = fmt.Errorf("mock error")
85+
}
86+
err := openGitHubProfile(tt.targetUser, tt.mockClient, mockBrowser)
87+
88+
if (err != nil) != tt.wantErr {
89+
t.Errorf("openGitHubProfile() error = %v, wantErr %v", err, tt.wantErr)
90+
return
91+
}
92+
93+
if !tt.wantErr && mockBrowser.LastURL != tt.wantURL {
94+
t.Errorf("openGitHubProfile() URL = %v, want %v", mockBrowser.LastURL, tt.wantURL)
95+
}
96+
})
97+
}
98+
}

cmd/skyline/skyline.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Package skyline provides the entry point for the GitHub Skyline Generator.
2+
// It generates a 3D model of GitHub contributions in STL format.
3+
package skyline
4+
5+
import (
6+
"fmt"
7+
"strings"
8+
"time"
9+
10+
"github.com/github/gh-skyline/internal/ascii"
11+
"github.com/github/gh-skyline/internal/errors"
12+
"github.com/github/gh-skyline/internal/github"
13+
"github.com/github/gh-skyline/internal/logger"
14+
"github.com/github/gh-skyline/internal/stl"
15+
"github.com/github/gh-skyline/internal/types"
16+
"github.com/github/gh-skyline/internal/utils"
17+
)
18+
19+
// GitHubClientInterface defines the methods for interacting with GitHub API
20+
type GitHubClientInterface interface {
21+
GetAuthenticatedUser() (string, error)
22+
GetUserJoinYear(username string) (int, error)
23+
FetchContributions(username string, year int) (*types.ContributionsResponse, error)
24+
}
25+
26+
// GenerateSkyline creates a 3D model with ASCII art preview of GitHub contributions for the specified year range, or "full lifetime" of the user
27+
func GenerateSkyline(startYear, endYear int, targetUser string, full bool, output string, artOnly bool) error {
28+
log := logger.GetLogger()
29+
30+
client, err := github.InitializeGitHubClient()
31+
if err != nil {
32+
return errors.New(errors.NetworkError, "failed to initialize GitHub client", err)
33+
}
34+
35+
if targetUser == "" {
36+
if err := log.Debug("No target user specified, using authenticated user"); err != nil {
37+
return err
38+
}
39+
username, err := client.GetAuthenticatedUser()
40+
if err != nil {
41+
return errors.New(errors.NetworkError, "failed to get authenticated user", err)
42+
}
43+
targetUser = username
44+
}
45+
46+
if full {
47+
joinYear, err := client.GetUserJoinYear(targetUser)
48+
if err != nil {
49+
return errors.New(errors.NetworkError, "failed to get user join year", err)
50+
}
51+
startYear = joinYear
52+
endYear = time.Now().Year()
53+
}
54+
55+
var allContributions [][][]types.ContributionDay
56+
for year := startYear; year <= endYear; year++ {
57+
contributions, err := fetchContributionData(client, targetUser, year)
58+
if err != nil {
59+
return err
60+
}
61+
allContributions = append(allContributions, contributions)
62+
63+
// Generate ASCII art for each year
64+
asciiArt, err := ascii.GenerateASCII(contributions, targetUser, year, (year == startYear) && !artOnly, !artOnly)
65+
if err != nil {
66+
if warnErr := log.Warning("Failed to generate ASCII preview: %v", err); warnErr != nil {
67+
return warnErr
68+
}
69+
} else {
70+
if year == startYear {
71+
// For first year, show full ASCII art including header
72+
fmt.Println(asciiArt)
73+
} else {
74+
// For subsequent years, skip the header
75+
lines := strings.Split(asciiArt, "\n")
76+
gridStart := 0
77+
for i, line := range lines {
78+
containsEmptyBlock := strings.Contains(line, string(ascii.EmptyBlock))
79+
containsFoundationLow := strings.Contains(line, string(ascii.FoundationLow))
80+
isNotOnlyEmptyBlocks := strings.Trim(line, string(ascii.EmptyBlock)) != ""
81+
82+
if (containsEmptyBlock || containsFoundationLow) && isNotOnlyEmptyBlocks {
83+
gridStart = i
84+
break
85+
}
86+
}
87+
// Print just the grid and user info
88+
fmt.Println(strings.Join(lines[gridStart:], "\n"))
89+
}
90+
}
91+
}
92+
93+
if !artOnly {
94+
// Generate filename
95+
outputPath := utils.GenerateOutputFilename(targetUser, startYear, endYear, output)
96+
97+
// Generate the STL file
98+
if len(allContributions) == 1 {
99+
return stl.GenerateSTL(allContributions[0], outputPath, targetUser, startYear)
100+
}
101+
return stl.GenerateSTLRange(allContributions, outputPath, targetUser, startYear, endYear)
102+
}
103+
104+
return nil
105+
}
106+
107+
// fetchContributionData retrieves and formats the contribution data for the specified year.
108+
func fetchContributionData(client *github.Client, username string, year int) ([][]types.ContributionDay, error) {
109+
response, err := client.FetchContributions(username, year)
110+
if err != nil {
111+
return nil, fmt.Errorf("failed to fetch contributions: %w", err)
112+
}
113+
114+
// Convert weeks data to 2D array for STL generation
115+
weeks := response.User.ContributionsCollection.ContributionCalendar.Weeks
116+
contributionGrid := make([][]types.ContributionDay, len(weeks))
117+
for i, week := range weeks {
118+
contributionGrid[i] = week.ContributionDays
119+
}
120+
121+
return contributionGrid, nil
122+
}

0 commit comments

Comments
 (0)