diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8883a33..ac35121 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -3,7 +3,7 @@ { "name": "Go", // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile - "image": "mcr.microsoft.com/devcontainers/go:1-1.23-bookworm", + "image": "mcr.microsoft.com/devcontainers/go:1-1.24-bookworm", "customizations": { "vscode": { "extensions": [ diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index bea4066..4ccaf2f 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -26,10 +26,10 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.23" + go-version-file: go.mod - name: Run Super-Linter - uses: super-linter/super-linter/slim@85f7611e0f7b53c8573cca84aa0ed4344f6f6a4d # v7.2.1 + uses: super-linter/super-linter/slim@4e8a7c2bf106c4c766c816b35ec612638dc9b6b2 # v7.3.0 env: VALIDATE_ALL_CODEBASE: true DEFAULT_BRANCH: "main" diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..38d2b97 --- /dev/null +++ b/cmd/root.go @@ -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) +} diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..c6bd877 --- /dev/null +++ b/cmd/root_test.go @@ -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) + } + }) + } +} diff --git a/cmd/skyline/skyline.go b/cmd/skyline/skyline.go new file mode 100644 index 0000000..de62e5e --- /dev/null +++ b/cmd/skyline/skyline.go @@ -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 +} diff --git a/cmd/skyline/skyline_test.go b/cmd/skyline/skyline_test.go new file mode 100644 index 0000000..05970a5 --- /dev/null +++ b/cmd/skyline/skyline_test.go @@ -0,0 +1,81 @@ +package skyline + +import ( + "testing" + + "github.com/github/gh-skyline/internal/github" + "github.com/github/gh-skyline/internal/testutil/fixtures" + "github.com/github/gh-skyline/internal/testutil/mocks" +) + +func TestGenerateSkyline(t *testing.T) { + // Save original initializer + originalInit := github.InitializeGitHubClient + defer func() { + github.InitializeGitHubClient = originalInit + }() + + tests := []struct { + name string + startYear int + endYear int + targetUser string + full bool + mockClient *mocks.MockGitHubClient + wantErr bool + }{ + { + name: "single year", + startYear: 2024, + endYear: 2024, + targetUser: "testuser", + full: false, + mockClient: &mocks.MockGitHubClient{ + Username: "testuser", + JoinYear: 2020, + MockData: fixtures.GenerateContributionsResponse("testuser", 2024), + }, + wantErr: false, + }, + { + name: "year range", + startYear: 2020, + endYear: 2024, + targetUser: "testuser", + full: false, + mockClient: &mocks.MockGitHubClient{ + Username: "testuser", + JoinYear: 2020, + MockData: fixtures.GenerateContributionsResponse("testuser", 2024), + }, + wantErr: false, + }, + { + name: "full range", + startYear: 2008, + endYear: 2024, + targetUser: "testuser", + full: true, + mockClient: &mocks.MockGitHubClient{ + Username: "testuser", + JoinYear: 2008, + MockData: fixtures.GenerateContributionsResponse("testuser", 2024), + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a closure that returns our mock client + github.InitializeGitHubClient = func() (*github.Client, error) { + return github.NewClient(tt.mockClient), nil + } + + err := GenerateSkyline(tt.startYear, tt.endYear, tt.targetUser, tt.full, "", false) + if (err != nil) != tt.wantErr { + t.Errorf("GenerateSkyline() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/go.mod b/go.mod index 96e4328..b9c022b 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,11 @@ module github.com/github/gh-skyline -go 1.22 - -toolchain go1.23.3 +go 1.24.1 require ( github.com/cli/go-gh/v2 v2.11.2 github.com/fogleman/gg v1.3.0 - github.com/spf13/cobra v1.8.1 + github.com/spf13/cobra v1.9.1 ) require ( @@ -22,14 +20,13 @@ require ( github.com/kr/text v0.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/muesli/termenv v0.15.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/pflag v1.0.6 // indirect github.com/thlib/go-timezone-local v0.0.6 // indirect - golang.org/x/image v0.23.0 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/term v0.28.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/image v0.25.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/term v0.30.0 // indirect + golang.org/x/text v0.23.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index a8d94af..58987b0 100644 --- a/go.sum +++ b/go.sum @@ -8,7 +8,7 @@ github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00= github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= github.com/cli/shurcooL-graphql v0.0.4 h1:6MogPnQJLjKkaXPyGqPRXOI2qCsQdqNfUY1QSJu2GuY= github.com/cli/shurcooL-graphql v0.0.4/go.mod h1:3waN4u02FiZivIV+p1y4d0Jo1jc6BViMA73C+sZo2fk= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -32,34 +32,31 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= -github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/thlib/go-timezone-local v0.0.6 h1:Ii3QJ4FhosL/+eCZl6Hsdr4DDU4tfevNoV83yAEo2tU= github.com/thlib/go-timezone-local v0.0.6/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI= -golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= -golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= +golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= +golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= -golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/ascii/block.go b/internal/ascii/block.go similarity index 100% rename from ascii/block.go rename to internal/ascii/block.go diff --git a/ascii/block_test.go b/internal/ascii/block_test.go similarity index 100% rename from ascii/block_test.go rename to internal/ascii/block_test.go diff --git a/ascii/generator.go b/internal/ascii/generator.go similarity index 98% rename from ascii/generator.go rename to internal/ascii/generator.go index 87954dc..1044619 100644 --- a/ascii/generator.go +++ b/internal/ascii/generator.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "github.com/github/gh-skyline/types" + "github.com/github/gh-skyline/internal/types" ) // ErrInvalidGrid is returned when the contribution grid is invalid diff --git a/ascii/generator_test.go b/internal/ascii/generator_test.go similarity index 99% rename from ascii/generator_test.go rename to internal/ascii/generator_test.go index 4ee56c9..7cdaf8c 100644 --- a/ascii/generator_test.go +++ b/internal/ascii/generator_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/github/gh-skyline/types" + "github.com/github/gh-skyline/internal/types" ) func TestGenerateASCII(t *testing.T) { diff --git a/ascii/text.go b/internal/ascii/text.go similarity index 100% rename from ascii/text.go rename to internal/ascii/text.go diff --git a/ascii/text_test.go b/internal/ascii/text_test.go similarity index 100% rename from ascii/text_test.go rename to internal/ascii/text_test.go diff --git a/errors/errors.go b/internal/errors/errors.go similarity index 100% rename from errors/errors.go rename to internal/errors/errors.go diff --git a/errors/errors_test.go b/internal/errors/errors_test.go similarity index 100% rename from errors/errors_test.go rename to internal/errors/errors_test.go diff --git a/github/client.go b/internal/github/client.go similarity index 97% rename from github/client.go rename to internal/github/client.go index 54e641b..8694f44 100644 --- a/github/client.go +++ b/internal/github/client.go @@ -6,8 +6,8 @@ import ( "fmt" "time" - "github.com/github/gh-skyline/errors" - "github.com/github/gh-skyline/types" + "github.com/github/gh-skyline/internal/errors" + "github.com/github/gh-skyline/internal/types" ) // APIClient interface defines the methods we need from the client diff --git a/github/client_test.go b/internal/github/client_test.go similarity index 97% rename from github/client_test.go rename to internal/github/client_test.go index 1d822ad..c83ede9 100644 --- a/github/client_test.go +++ b/internal/github/client_test.go @@ -4,9 +4,9 @@ import ( "testing" "time" - "github.com/github/gh-skyline/errors" - "github.com/github/gh-skyline/testutil/mocks" - "github.com/github/gh-skyline/types" + "github.com/github/gh-skyline/internal/errors" + "github.com/github/gh-skyline/internal/testutil/mocks" + "github.com/github/gh-skyline/internal/types" ) func TestGetAuthenticatedUser(t *testing.T) { diff --git a/internal/github/init.go b/internal/github/init.go new file mode 100644 index 0000000..c12eeed --- /dev/null +++ b/internal/github/init.go @@ -0,0 +1,20 @@ +// Package github provides a function to initialize the GitHub client. +package github + +import ( + "fmt" + + "github.com/cli/go-gh/v2/pkg/api" +) + +// ClientInitializer is a function type for initializing GitHub clients +type ClientInitializer func() (*Client, error) + +// InitializeGitHubClient is the default client initializer +var InitializeGitHubClient ClientInitializer = func() (*Client, error) { + apiClient, err := api.DefaultGraphQLClient() + if err != nil { + return nil, fmt.Errorf("failed to create GraphQL client: %w", err) + } + return NewClient(apiClient), nil +} diff --git a/logger/logger.go b/internal/logger/logger.go similarity index 100% rename from logger/logger.go rename to internal/logger/logger.go diff --git a/logger/logger_test.go b/internal/logger/logger_test.go similarity index 100% rename from logger/logger_test.go rename to internal/logger/logger_test.go diff --git a/stl/generator.go b/internal/stl/generator.go similarity index 98% rename from stl/generator.go rename to internal/stl/generator.go index 98cde8b..8dfb5bb 100644 --- a/stl/generator.go +++ b/internal/stl/generator.go @@ -4,10 +4,10 @@ import ( "fmt" "sync" - "github.com/github/gh-skyline/errors" - "github.com/github/gh-skyline/logger" - "github.com/github/gh-skyline/stl/geometry" - "github.com/github/gh-skyline/types" + "github.com/github/gh-skyline/internal/errors" + "github.com/github/gh-skyline/internal/logger" + "github.com/github/gh-skyline/internal/stl/geometry" + "github.com/github/gh-skyline/internal/types" ) // GenerateSTL creates a 3D model from GitHub contribution data and writes it to an STL file. diff --git a/stl/generator_test.go b/internal/stl/generator_test.go similarity index 99% rename from stl/generator_test.go rename to internal/stl/generator_test.go index 3ad8b17..eea9421 100644 --- a/stl/generator_test.go +++ b/internal/stl/generator_test.go @@ -7,7 +7,7 @@ import ( "sync" "testing" - "github.com/github/gh-skyline/types" + "github.com/github/gh-skyline/internal/types" ) // Test data setup diff --git a/stl/geometry/assets.go b/internal/stl/geometry/assets.go similarity index 98% rename from stl/geometry/assets.go rename to internal/stl/geometry/assets.go index 6f8023e..0f09b77 100644 --- a/stl/geometry/assets.go +++ b/internal/stl/geometry/assets.go @@ -5,7 +5,7 @@ import ( "fmt" "os" - "github.com/github/gh-skyline/errors" + "github.com/github/gh-skyline/internal/errors" ) //go:embed assets/* diff --git a/stl/geometry/assets/invertocat.png b/internal/stl/geometry/assets/invertocat.png similarity index 100% rename from stl/geometry/assets/invertocat.png rename to internal/stl/geometry/assets/invertocat.png diff --git a/stl/geometry/assets/monasans-medium.ttf b/internal/stl/geometry/assets/monasans-medium.ttf similarity index 100% rename from stl/geometry/assets/monasans-medium.ttf rename to internal/stl/geometry/assets/monasans-medium.ttf diff --git a/stl/geometry/assets/monasans-regular.ttf b/internal/stl/geometry/assets/monasans-regular.ttf similarity index 100% rename from stl/geometry/assets/monasans-regular.ttf rename to internal/stl/geometry/assets/monasans-regular.ttf diff --git a/stl/geometry/assets_test.go b/internal/stl/geometry/assets_test.go similarity index 100% rename from stl/geometry/assets_test.go rename to internal/stl/geometry/assets_test.go diff --git a/stl/geometry/geometry.go b/internal/stl/geometry/geometry.go similarity index 98% rename from stl/geometry/geometry.go rename to internal/stl/geometry/geometry.go index e804771..89f3583 100644 --- a/stl/geometry/geometry.go +++ b/internal/stl/geometry/geometry.go @@ -3,7 +3,7 @@ package geometry import ( "math" - "github.com/github/gh-skyline/types" + "github.com/github/gh-skyline/internal/types" ) // Model dimension constants define the basic measurements for the 3D model. diff --git a/stl/geometry/geometry_test.go b/internal/stl/geometry/geometry_test.go similarity index 98% rename from stl/geometry/geometry_test.go rename to internal/stl/geometry/geometry_test.go index fe9906a..63879eb 100644 --- a/stl/geometry/geometry_test.go +++ b/internal/stl/geometry/geometry_test.go @@ -4,7 +4,7 @@ import ( "math" "testing" - "github.com/github/gh-skyline/types" + "github.com/github/gh-skyline/internal/types" ) const ( diff --git a/stl/geometry/shapes.go b/internal/stl/geometry/shapes.go similarity index 97% rename from stl/geometry/shapes.go rename to internal/stl/geometry/shapes.go index cbe5958..5f731db 100644 --- a/stl/geometry/shapes.go +++ b/internal/stl/geometry/shapes.go @@ -2,8 +2,8 @@ package geometry import ( - "github.com/github/gh-skyline/errors" - "github.com/github/gh-skyline/types" + "github.com/github/gh-skyline/internal/errors" + "github.com/github/gh-skyline/internal/types" ) // CreateQuad creates two triangles forming a quadrilateral from four vertices. diff --git a/stl/geometry/shapes_test.go b/internal/stl/geometry/shapes_test.go similarity index 99% rename from stl/geometry/shapes_test.go rename to internal/stl/geometry/shapes_test.go index 31e7b4d..7b9b785 100644 --- a/stl/geometry/shapes_test.go +++ b/internal/stl/geometry/shapes_test.go @@ -4,7 +4,7 @@ import ( "math" "testing" - "github.com/github/gh-skyline/types" + "github.com/github/gh-skyline/internal/types" ) // TestCreateCuboidBase verifies cuboid base generation functionality. diff --git a/stl/geometry/text.go b/internal/stl/geometry/text.go similarity index 98% rename from stl/geometry/text.go rename to internal/stl/geometry/text.go index bee5245..e29127a 100644 --- a/stl/geometry/text.go +++ b/internal/stl/geometry/text.go @@ -6,8 +6,8 @@ import ( "os" "github.com/fogleman/gg" - "github.com/github/gh-skyline/errors" - "github.com/github/gh-skyline/types" + "github.com/github/gh-skyline/internal/errors" + "github.com/github/gh-skyline/internal/types" ) const ( diff --git a/stl/geometry/text_test.go b/internal/stl/geometry/text_test.go similarity index 100% rename from stl/geometry/text_test.go rename to internal/stl/geometry/text_test.go diff --git a/stl/geometry/vector.go b/internal/stl/geometry/vector.go similarity index 95% rename from stl/geometry/vector.go rename to internal/stl/geometry/vector.go index 5c6b897..cdbd9d5 100644 --- a/stl/geometry/vector.go +++ b/internal/stl/geometry/vector.go @@ -4,8 +4,8 @@ package geometry import ( "math" - "github.com/github/gh-skyline/errors" - "github.com/github/gh-skyline/types" + "github.com/github/gh-skyline/internal/errors" + "github.com/github/gh-skyline/internal/types" ) // validateVector checks if a vector's components are valid numbers diff --git a/stl/geometry/vector_test.go b/internal/stl/geometry/vector_test.go similarity index 98% rename from stl/geometry/vector_test.go rename to internal/stl/geometry/vector_test.go index e836ebd..c0759af 100644 --- a/stl/geometry/vector_test.go +++ b/internal/stl/geometry/vector_test.go @@ -4,7 +4,7 @@ import ( "math" "testing" - "github.com/github/gh-skyline/types" + "github.com/github/gh-skyline/internal/types" ) // Epsilon is declared in geometry_test.go diff --git a/stl/stl.go b/internal/stl/stl.go similarity index 97% rename from stl/stl.go rename to internal/stl/stl.go index 8d6c03f..a4fb5e8 100644 --- a/stl/stl.go +++ b/internal/stl/stl.go @@ -20,9 +20,9 @@ import ( "math" "os" - "github.com/github/gh-skyline/errors" - "github.com/github/gh-skyline/logger" - "github.com/github/gh-skyline/types" + "github.com/github/gh-skyline/internal/errors" + "github.com/github/gh-skyline/internal/logger" + "github.com/github/gh-skyline/internal/types" ) const ( diff --git a/stl/stl_test.go b/internal/stl/stl_test.go similarity index 98% rename from stl/stl_test.go rename to internal/stl/stl_test.go index cee1cdc..8229cae 100644 --- a/stl/stl_test.go +++ b/internal/stl/stl_test.go @@ -6,7 +6,7 @@ import ( "path/filepath" "testing" - "github.com/github/gh-skyline/types" + "github.com/github/gh-skyline/internal/types" ) // Helper function to verify STL file header diff --git a/testutil/fixtures/github.go b/internal/testutil/fixtures/github.go similarity index 96% rename from testutil/fixtures/github.go rename to internal/testutil/fixtures/github.go index 491baba..5498f9c 100644 --- a/testutil/fixtures/github.go +++ b/internal/testutil/fixtures/github.go @@ -5,7 +5,7 @@ package fixtures import ( "time" - "github.com/github/gh-skyline/types" + "github.com/github/gh-skyline/internal/types" ) // GenerateContributionsResponse creates a mock contributions response diff --git a/testutil/mocks/github.go b/internal/testutil/mocks/github.go similarity index 94% rename from testutil/mocks/github.go rename to internal/testutil/mocks/github.go index b03409e..8d6f906 100644 --- a/testutil/mocks/github.go +++ b/internal/testutil/mocks/github.go @@ -5,8 +5,8 @@ import ( "fmt" "time" - "github.com/github/gh-skyline/testutil/fixtures" - "github.com/github/gh-skyline/types" + "github.com/github/gh-skyline/internal/testutil/fixtures" + "github.com/github/gh-skyline/internal/types" ) // MockGitHubClient implements both GitHubClientInterface and APIClient interfaces diff --git a/types/types.go b/internal/types/types.go similarity index 100% rename from types/types.go rename to internal/types/types.go diff --git a/types/types_test.go b/internal/types/types_test.go similarity index 100% rename from types/types_test.go rename to internal/types/types_test.go diff --git a/internal/utils/utils.go b/internal/utils/utils.go new file mode 100644 index 0000000..61b681b --- /dev/null +++ b/internal/utils/utils.go @@ -0,0 +1,76 @@ +// Package utils are utility functions for the GitHub Skyline project +package utils + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +// Constants for GitHub launch year and default output file format +const ( + githubLaunchYear = 2008 + outputFileFormat = "%s-%s-github-skyline.stl" +) + +// ParseYearRange parses whether a year is a single year or a range of years. +func ParseYearRange(yearRange string) (startYear, endYear int, err error) { + if strings.Contains(yearRange, "-") { + parts := strings.Split(yearRange, "-") + if len(parts) != 2 { + return 0, 0, fmt.Errorf("invalid year range format") + } + startYear, err = strconv.Atoi(parts[0]) + if err != nil { + return 0, 0, err + } + endYear, err = strconv.Atoi(parts[1]) + if err != nil { + return 0, 0, err + } + } else { + year, err := strconv.Atoi(yearRange) + if err != nil { + return 0, 0, err + } + startYear, endYear = year, year + } + return startYear, endYear, validateYearRange(startYear, endYear) +} + +// validateYearRange checks if the years are within the range +// of GitHub's launch year to the current year and if +// the start year is not greater than the end year. +func validateYearRange(startYear, endYear int) error { + currentYear := time.Now().Year() + if startYear < githubLaunchYear || endYear > currentYear { + return fmt.Errorf("years must be between %d and %d", githubLaunchYear, currentYear) + } + if startYear > endYear { + return fmt.Errorf("start year cannot be after end year") + } + return nil +} + +// FormatYearRange returns a formatted string representation of the year range +func FormatYearRange(startYear, endYear int) string { + if startYear == endYear { + return fmt.Sprintf("%d", startYear) + } + // Use YYYY-YY format for multi-year ranges + return fmt.Sprintf("%04d-%02d", startYear, endYear%100) +} + +// GenerateOutputFilename creates a consistent filename for the STL output +func GenerateOutputFilename(user string, startYear, endYear int, output string) string { + if output != "" { + // Ensure the filename ends with .stl + if !strings.HasSuffix(strings.ToLower(output), ".stl") { + return output + ".stl" + } + return output + } + yearStr := FormatYearRange(startYear, endYear) + return fmt.Sprintf(outputFileFormat, user, yearStr) +} diff --git a/internal/utils/utils_test.go b/internal/utils/utils_test.go new file mode 100644 index 0000000..8253ed1 --- /dev/null +++ b/internal/utils/utils_test.go @@ -0,0 +1,175 @@ +package utils + +import "testing" + +func TestParseYearRange(t *testing.T) { + tests := []struct { + name string + yearRange string + wantStart int + wantEnd int + wantErr bool + wantErrString string + }{ + { + name: "single year", + yearRange: "2024", + wantStart: 2024, + wantEnd: 2024, + wantErr: false, + }, + { + name: "year range", + yearRange: "2020-2024", + wantStart: 2020, + wantEnd: 2024, + wantErr: false, + }, + { + name: "invalid format", + yearRange: "2020-2024-2025", + wantErr: true, + wantErrString: "invalid year range format", + }, + { + name: "invalid number", + yearRange: "abc-2024", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + start, end, err := ParseYearRange(tt.yearRange) + if (err != nil) != tt.wantErr { + t.Errorf("parseYearRange() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr && tt.wantErrString != "" && err.Error() != tt.wantErrString { + t.Errorf("parseYearRange() error = %v, wantErrString %v", err, tt.wantErrString) + return + } + if !tt.wantErr { + if start != tt.wantStart { + t.Errorf("parseYearRange() start = %v, want %v", start, tt.wantStart) + } + if end != tt.wantEnd { + t.Errorf("parseYearRange() end = %v, want %v", end, tt.wantEnd) + } + } + }) + } +} + +func TestValidateYearRange(t *testing.T) { + tests := []struct { + name string + startYear int + endYear int + wantErr bool + }{ + { + name: "valid range", + startYear: 2020, + endYear: 2024, + wantErr: false, + }, + { + name: "invalid start year", + startYear: 2007, + endYear: 2024, + wantErr: true, + }, + { + name: "start after end", + startYear: 2024, + endYear: 2020, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateYearRange(tt.startYear, tt.endYear) + if (err != nil) != tt.wantErr { + t.Errorf("validateYearRange() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestFormatYearRange(t *testing.T) { + tests := []struct { + name string + startYear int + endYear int + want string + }{ + { + name: "same year", + startYear: 2024, + endYear: 2024, + want: "2024", + }, + { + name: "different years", + startYear: 2020, + endYear: 2024, + want: "2020-24", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FormatYearRange(tt.startYear, tt.endYear) + if got != tt.want { + t.Errorf("formatYearRange() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGenerateOutputFilename(t *testing.T) { + tests := []struct { + name string + user string + startYear int + endYear int + output string + want string + }{ + { + name: "single year", + user: "testuser", + startYear: 2024, + endYear: 2024, + output: "", + want: "testuser-2024-github-skyline.stl", + }, + { + name: "year range", + user: "testuser", + startYear: 2020, + endYear: 2024, + output: "", + want: "testuser-2020-24-github-skyline.stl", + }, + { + name: "override", + user: "testuser", + startYear: 2020, + endYear: 2024, + output: "myoutput.stl", + want: "myoutput.stl", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GenerateOutputFilename(tt.user, tt.startYear, tt.endYear, tt.output) + if got != tt.want { + t.Errorf("generateOutputFilename() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/main.go b/main.go index a5a0cdc..ec1d8f9 100644 --- a/main.go +++ b/main.go @@ -1,305 +1,32 @@ -// Package main provides the entry point for the GitHub Skyline Generator. -// It generates a 3D model of GitHub contributions in STL format. +// Package main provides the entry point for the GitHub CLI gh-skyline extension. package main import ( - "fmt" + "context" "os" - "strconv" - "strings" - "time" - "github.com/cli/go-gh/v2/pkg/api" - "github.com/cli/go-gh/v2/pkg/browser" - "github.com/github/gh-skyline/ascii" - "github.com/github/gh-skyline/errors" - "github.com/github/gh-skyline/github" - "github.com/github/gh-skyline/logger" - "github.com/github/gh-skyline/stl" - "github.com/github/gh-skyline/types" - "github.com/spf13/cobra" + "github.com/github/gh-skyline/cmd" ) -// Browser interface matches browser.Browser functionality -type Browser interface { - Browse(url string) error -} - -// 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) -} +type exitCode int -// Constants for GitHub launch year and default output file format const ( - githubLaunchYear = 2008 - outputFileFormat = "%s-%s-github-skyline.stl" -) - -// 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 = &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: func(_ *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 := 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 := parseYearRange(yearRange) - if err != nil { - return fmt.Errorf("invalid year range: %v", err) - } - - return generateSkyline(startYear, endYear, user, full) - }, - } + exitOK exitCode = 0 + exitError exitCode = 1 ) -// init sets up command line flags for the skyline CLI tool -func init() { - rootCmd.Flags().StringVarP(&yearRange, "year", "y", fmt.Sprintf("%d", time.Now().Year()), "Year or year range (e.g., 2024 or 2014-2024)") - rootCmd.Flags().StringVarP(&user, "user", "u", "", "GitHub username (optional, defaults to authenticated user)") - rootCmd.Flags().BoolVarP(&full, "full", "f", false, "Generate contribution graph from join year to current year") - rootCmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging") - rootCmd.Flags().BoolVarP(&web, "web", "w", false, "Open GitHub profile (authenticated or specified user).") - rootCmd.Flags().BoolVarP(&artOnly, "art-only", "a", false, "Generate only ASCII preview") - rootCmd.Flags().StringVarP(&output, "output", "o", "", "Output file path (optional)") -} - -// main initializes and executes the root command for the GitHub Skyline CLI func main() { - if err := rootCmd.Execute(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } -} - -// formatYearRange returns a formatted string representation of the year range -func formatYearRange(startYear, endYear int) string { - if startYear == endYear { - return fmt.Sprintf("%d", startYear) - } - // Use YYYY-YY format for multi-year ranges - return fmt.Sprintf("%04d-%02d", startYear, endYear%100) -} - -// generateOutputFilename creates a consistent filename for the STL output -func generateOutputFilename(user string, startYear, endYear int) string { - if output != "" { - // Ensure the filename ends with .stl - if !strings.HasSuffix(strings.ToLower(output), ".stl") { - return output + ".stl" - } - return output - } - yearStr := formatYearRange(startYear, endYear) - return fmt.Sprintf(outputFileFormat, user, yearStr) + code := start() + os.Exit(int(code)) } -// 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) error { - log := logger.GetLogger() - - client, err := 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 := generateOutputFilename(targetUser, startYear, endYear) - - // 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 -} - -// Variable for client initialization - allows for testing -var initializeGitHubClient = defaultGitHubClient - -// defaultGitHubClient is the default implementation of client initialization -func defaultGitHubClient() (*github.Client, error) { - apiClient, err := api.DefaultGraphQLClient() - if err != nil { - return nil, fmt.Errorf("failed to create GraphQL client: %w", err) - } - return github.NewClient(apiClient), 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 -} - -// Parse year range string (e.g., "2024" or "2014-2024") -func parseYearRange(yearRange string) (startYear, endYear int, err error) { - if strings.Contains(yearRange, "-") { - parts := strings.Split(yearRange, "-") - if len(parts) != 2 { - return 0, 0, fmt.Errorf("invalid year range format") - } - startYear, err = strconv.Atoi(parts[0]) - if err != nil { - return 0, 0, err - } - endYear, err = strconv.Atoi(parts[1]) - if err != nil { - return 0, 0, err - } - } else { - year, err := strconv.Atoi(yearRange) - if err != nil { - return 0, 0, err - } - startYear, endYear = year, year - } - return startYear, endYear, validateYearRange(startYear, endYear) -} - -// validateYearRange checks if the years are within the range -// of GitHub's launch year to the current year and if -// the start year is not greater than the end year. -func validateYearRange(startYear, endYear int) error { - currentYear := time.Now().Year() - if startYear < githubLaunchYear || endYear > currentYear { - return fmt.Errorf("years must be between %d and %d", githubLaunchYear, currentYear) - } - if startYear > endYear { - return fmt.Errorf("start year cannot be after end year") - } - return nil -} +func start() exitCode { + exitCode := exitOK + ctx := context.Background() -// openGitHubProfile opens the GitHub profile page for the specified user or authenticated user -func openGitHubProfile(targetUser string, client 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 + if err := cmd.Execute(ctx); err != nil { + exitCode = exitError } - profileURL := fmt.Sprintf("https://github.com/%s", targetUser) - return b.Browse(profileURL) + return exitCode } diff --git a/main_test.go b/main_test.go deleted file mode 100644 index 242d6ef..0000000 --- a/main_test.go +++ /dev/null @@ -1,311 +0,0 @@ -package main - -import ( - "testing" - - "fmt" - - "github.com/github/gh-skyline/github" - "github.com/github/gh-skyline/testutil/fixtures" - "github.com/github/gh-skyline/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 TestFormatYearRange(t *testing.T) { - tests := []struct { - name string - startYear int - endYear int - want string - }{ - { - name: "same year", - startYear: 2024, - endYear: 2024, - want: "2024", - }, - { - name: "different years", - startYear: 2020, - endYear: 2024, - want: "2020-24", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := formatYearRange(tt.startYear, tt.endYear) - if got != tt.want { - t.Errorf("formatYearRange() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestGenerateOutputFilename(t *testing.T) { - tests := []struct { - name string - user string - startYear int - endYear int - want string - }{ - { - name: "single year", - user: "testuser", - startYear: 2024, - endYear: 2024, - want: "testuser-2024-github-skyline.stl", - }, - { - name: "year range", - user: "testuser", - startYear: 2020, - endYear: 2024, - want: "testuser-2020-24-github-skyline.stl", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := generateOutputFilename(tt.user, tt.startYear, tt.endYear) - if got != tt.want { - t.Errorf("generateOutputFilename() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestParseYearRange(t *testing.T) { - tests := []struct { - name string - yearRange string - wantStart int - wantEnd int - wantErr bool - wantErrString string - }{ - { - name: "single year", - yearRange: "2024", - wantStart: 2024, - wantEnd: 2024, - wantErr: false, - }, - { - name: "year range", - yearRange: "2020-2024", - wantStart: 2020, - wantEnd: 2024, - wantErr: false, - }, - { - name: "invalid format", - yearRange: "2020-2024-2025", - wantErr: true, - wantErrString: "invalid year range format", - }, - { - name: "invalid number", - yearRange: "abc-2024", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - start, end, err := parseYearRange(tt.yearRange) - if (err != nil) != tt.wantErr { - t.Errorf("parseYearRange() error = %v, wantErr %v", err, tt.wantErr) - return - } - if tt.wantErr && tt.wantErrString != "" && err.Error() != tt.wantErrString { - t.Errorf("parseYearRange() error = %v, wantErrString %v", err, tt.wantErrString) - return - } - if !tt.wantErr { - if start != tt.wantStart { - t.Errorf("parseYearRange() start = %v, want %v", start, tt.wantStart) - } - if end != tt.wantEnd { - t.Errorf("parseYearRange() end = %v, want %v", end, tt.wantEnd) - } - } - }) - } -} - -func TestValidateYearRange(t *testing.T) { - tests := []struct { - name string - startYear int - endYear int - wantErr bool - }{ - { - name: "valid range", - startYear: 2020, - endYear: 2024, - wantErr: false, - }, - { - name: "invalid start year", - startYear: 2007, - endYear: 2024, - wantErr: true, - }, - { - name: "start after end", - startYear: 2024, - endYear: 2020, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateYearRange(tt.startYear, tt.endYear) - if (err != nil) != tt.wantErr { - t.Errorf("validateYearRange() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -func TestGenerateSkyline(t *testing.T) { - // Save original client creation function - originalInitFn := initializeGitHubClient - defer func() { - initializeGitHubClient = originalInitFn - }() - - tests := []struct { - name string - startYear int - endYear int - targetUser string - full bool - mockClient *mocks.MockGitHubClient - wantErr bool - }{ - { - name: "single year", - startYear: 2024, - endYear: 2024, - targetUser: "testuser", - full: false, - mockClient: &mocks.MockGitHubClient{ - Username: "testuser", - JoinYear: 2020, - MockData: fixtures.GenerateContributionsResponse("testuser", 2024), - }, - wantErr: false, - }, - { - name: "year range", - startYear: 2020, - endYear: 2024, - targetUser: "testuser", - full: false, - mockClient: &mocks.MockGitHubClient{ - Username: "testuser", - JoinYear: 2020, - MockData: fixtures.GenerateContributionsResponse("testuser", 2024), - }, - wantErr: false, - }, - { - name: "full range", - startYear: 2008, - endYear: 2024, - targetUser: "testuser", - full: true, - mockClient: &mocks.MockGitHubClient{ - Username: "testuser", - JoinYear: 2008, - MockData: fixtures.GenerateContributionsResponse("testuser", 2024), - }, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Override the client initialization for testing - initializeGitHubClient = func() (*github.Client, error) { - return github.NewClient(tt.mockClient), nil - } - - err := generateSkyline(tt.startYear, tt.endYear, tt.targetUser, tt.full) - if (err != nil) != tt.wantErr { - t.Errorf("generateSkyline() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -// 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) - } - }) - } -}