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/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) - } - }) - } -}