From aa9ca4311560e121fd922c736470d59c2067845c Mon Sep 17 00:00:00 2001 From: Chris Reddington <791642+chrisreddington@users.noreply.github.com> Date: Thu, 23 Jan 2025 21:02:25 +0000 Subject: [PATCH 1/5] refactor: move several packages to internal folder --- {ascii => internal/ascii}/block.go | 0 {ascii => internal/ascii}/block_test.go | 0 {ascii => internal/ascii}/generator.go | 2 +- {ascii => internal/ascii}/generator_test.go | 2 +- {ascii => internal/ascii}/text.go | 0 {ascii => internal/ascii}/text_test.go | 0 {errors => internal/errors}/errors.go | 0 {errors => internal/errors}/errors_test.go | 0 {github => internal/github}/client.go | 4 ++-- {github => internal/github}/client_test.go | 6 +++--- {logger => internal/logger}/logger.go | 0 {logger => internal/logger}/logger_test.go | 0 {stl => internal/stl}/generator.go | 8 ++++---- {stl => internal/stl}/generator_test.go | 2 +- {stl => internal/stl}/geometry/assets.go | 2 +- .../stl}/geometry/assets/invertocat.png | Bin .../stl}/geometry/assets/monasans-medium.ttf | Bin .../stl}/geometry/assets/monasans-regular.ttf | Bin {stl => internal/stl}/geometry/assets_test.go | 0 {stl => internal/stl}/geometry/geometry.go | 2 +- {stl => internal/stl}/geometry/geometry_test.go | 2 +- {stl => internal/stl}/geometry/shapes.go | 4 ++-- {stl => internal/stl}/geometry/shapes_test.go | 2 +- {stl => internal/stl}/geometry/text.go | 4 ++-- {stl => internal/stl}/geometry/text_test.go | 0 {stl => internal/stl}/geometry/vector.go | 4 ++-- {stl => internal/stl}/geometry/vector_test.go | 2 +- {stl => internal/stl}/stl.go | 6 +++--- {stl => internal/stl}/stl_test.go | 2 +- {testutil => internal/testutil}/fixtures/github.go | 2 +- {testutil => internal/testutil}/mocks/github.go | 4 ++-- {types => internal/types}/types.go | 0 {types => internal/types}/types_test.go | 0 main.go | 12 ++++++------ main_test.go | 6 +++--- 35 files changed, 39 insertions(+), 39 deletions(-) rename {ascii => internal/ascii}/block.go (100%) rename {ascii => internal/ascii}/block_test.go (100%) rename {ascii => internal/ascii}/generator.go (98%) rename {ascii => internal/ascii}/generator_test.go (99%) rename {ascii => internal/ascii}/text.go (100%) rename {ascii => internal/ascii}/text_test.go (100%) rename {errors => internal/errors}/errors.go (100%) rename {errors => internal/errors}/errors_test.go (100%) rename {github => internal/github}/client.go (97%) rename {github => internal/github}/client_test.go (97%) rename {logger => internal/logger}/logger.go (100%) rename {logger => internal/logger}/logger_test.go (100%) rename {stl => internal/stl}/generator.go (98%) rename {stl => internal/stl}/generator_test.go (99%) rename {stl => internal/stl}/geometry/assets.go (98%) rename {stl => internal/stl}/geometry/assets/invertocat.png (100%) rename {stl => internal/stl}/geometry/assets/monasans-medium.ttf (100%) rename {stl => internal/stl}/geometry/assets/monasans-regular.ttf (100%) rename {stl => internal/stl}/geometry/assets_test.go (100%) rename {stl => internal/stl}/geometry/geometry.go (98%) rename {stl => internal/stl}/geometry/geometry_test.go (98%) rename {stl => internal/stl}/geometry/shapes.go (97%) rename {stl => internal/stl}/geometry/shapes_test.go (99%) rename {stl => internal/stl}/geometry/text.go (98%) rename {stl => internal/stl}/geometry/text_test.go (100%) rename {stl => internal/stl}/geometry/vector.go (95%) rename {stl => internal/stl}/geometry/vector_test.go (98%) rename {stl => internal/stl}/stl.go (97%) rename {stl => internal/stl}/stl_test.go (98%) rename {testutil => internal/testutil}/fixtures/github.go (96%) rename {testutil => internal/testutil}/mocks/github.go (94%) rename {types => internal/types}/types.go (100%) rename {types => internal/types}/types_test.go (100%) 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/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/main.go b/main.go index a5a0cdc..d8b8179 100644 --- a/main.go +++ b/main.go @@ -11,12 +11,12 @@ import ( "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/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/spf13/cobra" ) diff --git a/main_test.go b/main_test.go index 242d6ef..3bc8530 100644 --- a/main_test.go +++ b/main_test.go @@ -5,9 +5,9 @@ import ( "fmt" - "github.com/github/gh-skyline/github" - "github.com/github/gh-skyline/testutil/fixtures" - "github.com/github/gh-skyline/testutil/mocks" + "github.com/github/gh-skyline/internal/github" + "github.com/github/gh-skyline/internal/testutil/fixtures" + "github.com/github/gh-skyline/internal/testutil/mocks" ) // MockBrowser implements the Browser interface From 85822ea6f305a505274945c53c5ebc243016eab8 Mon Sep 17 00:00:00 2001 From: Chris Reddington <791642+chrisreddington@users.noreply.github.com> Date: Thu, 23 Jan 2025 23:29:02 +0000 Subject: [PATCH 2/5] Refactor main.go into cmd, utils packages and the github client. --- cmd/root.go | 122 ++++++++++++++ cmd/root_test.go | 98 +++++++++++ cmd/skyline/skyline.go | 122 ++++++++++++++ cmd/skyline/skyline_test.go | 81 +++++++++ internal/github/init.go | 20 +++ internal/utils/utils.go | 76 +++++++++ internal/utils/utils_test.go | 175 ++++++++++++++++++++ main.go | 301 ++------------------------------- main_test.go | 311 ----------------------------------- 9 files changed, 708 insertions(+), 598 deletions(-) create mode 100644 cmd/root.go create mode 100644 cmd/root_test.go create mode 100644 cmd/skyline/skyline.go create mode 100644 cmd/skyline/skyline_test.go create mode 100644 internal/github/init.go create mode 100644 internal/utils/utils.go create mode 100644 internal/utils/utils_test.go delete mode 100644 main_test.go diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..fcda959 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,122 @@ +// 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/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 = &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 := 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) + }, + } +) + +// init 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)") +} + +func init() { + initFlags() +} + +// Execute initializes and executes the root command for the GitHub Skyline CLI +func Execute(context context.Context) error { + if err := rootCmd.Execute(); err != nil { + return err + } + return nil +} + +// 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 + } + + profileURL := fmt.Sprintf("https://github.com/%s", 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/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/internal/utils/utils.go b/internal/utils/utils.go new file mode 100644 index 0000000..17f5f5c --- /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" +) + +// 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 +} + +// 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 d8b8179..0fc58be 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/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/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 := mainRun() + 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 mainRun() 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 3bc8530..0000000 --- a/main_test.go +++ /dev/null @@ -1,311 +0,0 @@ -package main - -import ( - "testing" - - "fmt" - - "github.com/github/gh-skyline/internal/github" - "github.com/github/gh-skyline/internal/testutil/fixtures" - "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 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) - } - }) - } -} From 95b3bae548778452742c5337168715c8bd1915ce Mon Sep 17 00:00:00 2001 From: Chris Reddington <791642+chrisreddington@users.noreply.github.com> Date: Sun, 26 Jan 2025 22:55:48 +0000 Subject: [PATCH 3/5] fix: update GitHub profile URL to use default hostname --- cmd/root.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/root.go b/cmd/root.go index fcda959..1bc1ae0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,6 +7,7 @@ import ( "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" @@ -117,6 +118,7 @@ func openGitHubProfile(targetUser string, client skyline.GitHubClientInterface, targetUser = username } - profileURL := fmt.Sprintf("https://github.com/%s", targetUser) + hostname, _ := auth.DefaultHost() + profileURL := fmt.Sprintf("https://%s/%s", hostname, targetUser) return b.Browse(profileURL) } From b88e36aa554cbf065ed420a5ebe569aab1980843 Mon Sep 17 00:00:00 2001 From: Chris Reddington <791642+chrisreddington@users.noreply.github.com> Date: Wed, 29 Jan 2025 12:20:45 +0000 Subject: [PATCH 4/5] refactor: restructure root command and improve command handling in CLI tool --- cmd/root.go | 97 ++++++++++++++++++++++++++++------------------------- main.go | 4 +-- 2 files changed, 53 insertions(+), 48 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 1bc1ae0..38d2b97 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -26,11 +26,13 @@ var ( 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. +// 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. @@ -46,40 +48,23 @@ ASCII Preview Legend: 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 := 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) - }, + 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 +} -// init sets up command line flags for the skyline CLI tool +// 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)") @@ -91,24 +76,44 @@ func initFlags() { flags.StringVarP(&output, "output", "o", "", "Output file path (optional)") } -func init() { - initFlags() -} +// 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 + } + } -// Execute initializes and executes the root command for the GitHub Skyline CLI -func Execute(context context.Context) error { - if err := rootCmd.Execute(); err != nil { - return err + client, err := github.InitializeGitHubClient() + if err != nil { + return errors.New(errors.NetworkError, "failed to initialize GitHub client", err) } - return nil + + 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 +// 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 +// 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() diff --git a/main.go b/main.go index 0fc58be..837dc3f 100644 --- a/main.go +++ b/main.go @@ -16,11 +16,11 @@ const ( ) func main() { - code := mainRun() + code := Start() os.Exit(int(code)) } -func mainRun() exitCode { +func Start() exitCode { exitCode := exitOK ctx := context.Background() From 6f0782f881a3fd5a5a2c537ff14da2d3e21193c9 Mon Sep 17 00:00:00 2001 From: Chris Reddington <791642+chrisreddington@users.noreply.github.com> Date: Mon, 10 Feb 2025 13:14:47 +0000 Subject: [PATCH 5/5] fix: correct comments and function naming conventions in utils and main packages --- internal/utils/utils.go | 4 ++-- main.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 17f5f5c..61b681b 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -1,4 +1,4 @@ -// package utils are utility functions for the GitHub Skyline project +// Package utils are utility functions for the GitHub Skyline project package utils import ( @@ -14,7 +14,7 @@ const ( outputFileFormat = "%s-%s-github-skyline.stl" ) -// Parse year range string (e.g., "2024" or "2014-2024") +// 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, "-") diff --git a/main.go b/main.go index 837dc3f..ec1d8f9 100644 --- a/main.go +++ b/main.go @@ -16,11 +16,11 @@ const ( ) func main() { - code := Start() + code := start() os.Exit(int(code)) } -func Start() exitCode { +func start() exitCode { exitCode := exitOK ctx := context.Background()