diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 6c71182..8883a33 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -14,7 +14,6 @@ "github.copilot-workspace", "GitHub.vscode-pull-request-github", "GitHub.remotehub", - "GitHub.vscode-codeql", "golang.Go" ] } diff --git a/.github/workflows/baseline.yml b/.github/workflows/baseline.yml deleted file mode 100644 index 8fa8481..0000000 --- a/.github/workflows/baseline.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Validate Repository Configuration - -permissions: - contents: read - -on: - push: - branches: [main] - pull_request: - branches: [main] - workflow_dispatch: - -jobs: - validate: - name: Validate Baseline Configuration - uses: chrisreddington/reusable-workflows/.github/workflows/baseline-validator.yml@d62f6e0cbe864707a620bba0be92695711514442 - with: - required-features: "ghcr.io/devcontainers/features/github-cli:1" diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 8166258..bea4066 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -29,7 +29,7 @@ jobs: go-version: "1.23" - name: Run Super-Linter - uses: super-linter/super-linter/slim@e1cb86b6e8d119f789513668b4b30bf17fe1efe4 # v7.2.0 + uses: super-linter/super-linter/slim@85f7611e0f7b53c8573cca84aa0ed4344f6f6a4d # v7.2.1 env: VALIDATE_ALL_CODEBASE: true DEFAULT_BRANCH: "main" diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..bb6d2a4 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,74 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +- The use of sexualized language or imagery and unwelcome sexual attention or + advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or electronic + address, without explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project email +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at . All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 85f38d8..3bd7be2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,8 @@ # Contributing -[fork]: https://github.com/github/REPO/fork -[pr]: https://github.com/github/REPO/compare -[style]: https://github.com/github/REPO/blob/main/.golangci.yaml +[fork]: https://github.com/github/gh-skyline/fork +[pr]: https://github.com/github/gh-skyline/compare +[style]: https://github.com/github/gh-skyline/blob/main/.github/linters/.golangci.yml Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. @@ -34,10 +34,10 @@ The environment will be ready to use in a few minutes. ### Local development environment -These are one time installations required to be able to test your changes locally as part of the pull request (PR) submission process. +These are one-time installations required to be able to test your changes locally as part of the pull request (PR) submission process. 1. install Go [through download](https://go.dev/doc/install) | [through Homebrew](https://formulae.brew.sh/formula/go) -1. [install golangci-lint](https://golangci-lint.run/usage/install/#local-installation) +1. [install golangci-lint](https://golangci-lint.run/welcome/install/#local-installation) ### Building the extension diff --git a/README.md b/README.md index 6416604..c129177 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ You can run the `gh skyline` command with the following flags: - Examples: `gh skyline --year 2020`, `gh skyline --year 2014-2024` - `-w`, `--web`: Open the GitHub profile for the authenticated or specified user. - Example: `gh skyline --web`, `gh skyline --user mona --web` +- `-a`, `--art-only`: Show the ASCII art preview without generating an STL file. ### Examples @@ -78,6 +79,12 @@ Generate a skyline from the user's join year to the current year: gh skyline --full ``` +Generate only the ASCII preview for a skyline: + +```bash +gh skyline --art-only +``` + Enable debug logging: ```bash @@ -113,6 +120,10 @@ The extension generates ASCII art in terminal while loading, a unique and fun wa - `'▓'` High level: Heavy contribution activity - `'╻┃╽'` Top level: Last block with contributions in the week (Low, Medium, High) +## Visualizing your Skyline + +Once you have generated your STL file, you can visualize it using 3D modeling or 3D printing software. But did you know that you can upload your STL file to a GitHub repository and view your Skyline there? For example, take a look at [@chrisreddington's GitHub Skyline from 2011 - 2024](https://github.com/chrisreddington/chrisreddington/blob/master/chrisreddington-11-24-github-skyline.stl). + ## Project Structure ```text diff --git a/ascii/generator.go b/ascii/generator.go index cba3457..87954dc 100644 --- a/ascii/generator.go +++ b/ascii/generator.go @@ -16,7 +16,7 @@ var ErrInvalidGrid = errors.New("invalid contribution grid") // GenerateASCII creates a 2D ASCII art representation of the contribution data. // It returns the generated ASCII art as a string and an error if the operation fails. // When includeHeader is true, the output includes the header template. -func GenerateASCII(contributionGrid [][]types.ContributionDay, username string, year int, includeHeader bool) (string, error) { +func GenerateASCII(contributionGrid [][]types.ContributionDay, username string, year int, includeHeader bool, includeUserInfo bool) (string, error) { if len(contributionGrid) == 0 { return "", ErrInvalidGrid } @@ -60,7 +60,10 @@ func GenerateASCII(contributionGrid [][]types.ContributionDay, username string, if day.ContributionCount == -1 { asciiGrid[dayIdx][weekIdx] = FutureBlock } else { - normalized := float64(day.ContributionCount) / float64(maxContributions) + normalized := 0.0 + if maxContributions != 0 { + normalized = float64(day.ContributionCount) / float64(maxContributions) + } asciiGrid[dayIdx][weekIdx] = getBlock(normalized, dayIdx, nonZeroCount) } } @@ -74,10 +77,12 @@ func GenerateASCII(contributionGrid [][]types.ContributionDay, username string, buffer.WriteRune('\n') } - // Add centered user info below - buffer.WriteString("\n") - buffer.WriteString(centerText(username)) - buffer.WriteString(centerText(fmt.Sprintf("%d", year))) + if includeUserInfo { + // Add centered user info below + buffer.WriteString("\n") + buffer.WriteString(centerText(username)) + buffer.WriteString(centerText(fmt.Sprintf("%d", year))) + } return buffer.String(), nil } diff --git a/ascii/generator_test.go b/ascii/generator_test.go index a0ed580..4ee56c9 100644 --- a/ascii/generator_test.go +++ b/ascii/generator_test.go @@ -32,16 +32,24 @@ func TestGenerateASCII(t *testing.T) { includeHeader: false, wantErr: false, }, + { + name: "no header", + grid: makeTestGrid(3, 7), + user: "testuser", + year: 2023, + includeHeader: false, + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := GenerateASCII(tt.grid, tt.user, tt.year, tt.includeHeader) + result, err := GenerateASCII(tt.grid, tt.user, tt.year, tt.includeHeader, tt.includeHeader) if (err != nil) != tt.wantErr { t.Errorf("GenerateASCII() error = %v, wantErr %v", err, tt.wantErr) return } - if !tt.wantErr { + if !tt.wantErr && tt.includeHeader { // Existing validation code... if !strings.Contains(result, "testuser") { t.Error("Generated ASCII should contain username") @@ -52,6 +60,20 @@ func TestGenerateASCII(t *testing.T) { if !strings.Contains(result, string(EmptyBlock)) { t.Error("Generated ASCII should contain empty blocks") } + if !strings.Contains(result, HeaderTemplate) { + t.Error("Generated ASCII should contain header") + } + } + if !tt.wantErr && !tt.includeHeader { + if strings.Contains(result, "testuser") { + t.Error("Generated ASCII should exclude username when requested") + } + if strings.Contains(result, "2023") { + t.Error("Generated ASCII should exclude year when requested") + } + if strings.Contains(result, HeaderTemplate) { + t.Error("Generated ASCII should exclude header when requested") + } } }) } @@ -97,3 +119,63 @@ func TestGetBlock(t *testing.T) { }) } } + +// TestGenerateASCIIZeroContributions tests the GenerateASCII function with zero contributions. +// It verifies that the skyline consists of empty blocks and appropriately handles the header and footer. +func TestGenerateASCIIZeroContributions(t *testing.T) { + tests := []struct { + name string + includeHeaderAndFooter bool + }{ + { + name: "Zero contributions without header", + includeHeaderAndFooter: false, + }, + { + name: "Zero contributions with header", + includeHeaderAndFooter: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a test grid with zero contributions + grid := makeTestGrid(3, 7) + for i := range grid { + for j := range grid[i] { + grid[i][j].ContributionCount = 0 + } + } + + // Generate ASCII art + result, err := GenerateASCII(grid, "testuser", 2023, tt.includeHeaderAndFooter, tt.includeHeaderAndFooter) + if err != nil { + t.Fatalf("GenerateASCII() returned an error: %v", err) + } + + lines := strings.Split(result, "\n") + + // Determine the starting line of the skyline + skylineStart := 0 + if tt.includeHeaderAndFooter { + // Assuming HeaderTemplate has a fixed number of lines + headerLines := strings.Count(HeaderTemplate, "\n") + skylineStart = headerLines + 1 // +1 for the additional newline after header + } + + // Verify the skyline has at least 7 lines + if len(lines) < skylineStart+7 { + t.Fatalf("Expected at least %d lines for skyline, got %d", skylineStart+7, len(lines)) + } + + // Check each line of the skyline for empty blocks + for i := skylineStart; i < skylineStart+7; i++ { + for _, ch := range lines[i] { + if ch != EmptyBlock { + t.Errorf("Expected empty block in skyline, got '%c' on line %d", ch, i+1) + } + } + } + }) + } +} diff --git a/github/client.go b/github/client.go index ed11802..54e641b 100644 --- a/github/client.go +++ b/github/client.go @@ -3,10 +3,7 @@ package github import ( - "bytes" - "encoding/json" "fmt" - "io" "time" "github.com/github/gh-skyline/errors" @@ -15,8 +12,7 @@ import ( // APIClient interface defines the methods we need from the client type APIClient interface { - Get(path string, response interface{}) error - Post(path string, body io.Reader, response interface{}) error + Do(query string, variables map[string]interface{}, response interface{}) error } // Client holds the API client @@ -31,17 +27,31 @@ func NewClient(apiClient APIClient) *Client { // GetAuthenticatedUser fetches the authenticated user's login name from GitHub. func (c *Client) GetAuthenticatedUser() (string, error) { - response := struct{ Login string }{} - err := c.api.Get("user", &response) + // GraphQL query to fetch the authenticated user's login. + query := ` + query { + viewer { + login + } + }` + + var response struct { + Viewer struct { + Login string `json:"login"` + } `json:"viewer"` + } + + // Execute the GraphQL query. + err := c.api.Do(query, nil, &response) if err != nil { return "", errors.New(errors.NetworkError, "failed to fetch authenticated user", err) } - if response.Login == "" { + if response.Viewer.Login == "" { return "", errors.New(errors.ValidationError, "received empty username from GitHub API", nil) } - return response.Login, nil + return response.Viewer.Login, nil } // FetchContributions retrieves the contribution data for a given username and year from GitHub. @@ -54,9 +64,10 @@ func (c *Client) FetchContributions(username string, year int) (*types.Contribut return nil, errors.New(errors.ValidationError, "year cannot be before GitHub's launch (2008)", nil) } - startDate := fmt.Sprintf("%d-01-01", year) - endDate := fmt.Sprintf("%d-12-31", year) + startDate := fmt.Sprintf("%d-01-01T00:00:00Z", year) + endDate := fmt.Sprintf("%d-12-31T23:59:59Z", year) + // GraphQL query to fetch the user's contributions within the specified date range. query := ` query ContributionGraph($username: String!, $from: DateTime!, $to: DateTime!) { user(login: $username) { @@ -77,34 +88,23 @@ func (c *Client) FetchContributions(username string, year int) (*types.Contribut variables := map[string]interface{}{ "username": username, - "from": startDate + "T00:00:00Z", - "to": endDate + "T23:59:59Z", + "from": startDate, + "to": endDate, } - payload := struct { - Query string `json:"query"` - Variables map[string]interface{} `json:"variables"` - }{ - Query: query, - Variables: variables, - } + var response types.ContributionsResponse - body, err := json.Marshal(payload) + // Execute the GraphQL query. + err := c.api.Do(query, variables, &response) if err != nil { - return nil, err - } - - var resp types.ContributionsResponse - if err := c.api.Post("graphql", bytes.NewBuffer(body), &resp); err != nil { - return nil, errors.New(errors.GraphQLError, "failed to fetch contributions", err) + return nil, errors.New(errors.NetworkError, "failed to fetch contributions", err) } - // Validate response - if resp.Data.User.Login == "" { - return nil, errors.New(errors.GraphQLError, "user not found", nil) + if response.User.Login == "" { + return nil, errors.New(errors.ValidationError, "received empty username from GitHub API", nil) } - return &resp, nil + return &response, nil } // GetUserJoinYear fetches the year a user joined GitHub using the GitHub API. @@ -113,6 +113,7 @@ func (c *Client) GetUserJoinYear(username string) (int, error) { return 0, errors.New(errors.ValidationError, "username cannot be empty", nil) } + // GraphQL query to fetch the user's account creation date. query := ` query UserJoinDate($username: String!) { user(login: $username) { @@ -124,35 +125,23 @@ func (c *Client) GetUserJoinYear(username string) (int, error) { "username": username, } - payload := struct { - Query string `json:"query"` - Variables map[string]interface{} `json:"variables"` - }{ - Query: query, - Variables: variables, + var response struct { + User struct { + CreatedAt time.Time `json:"createdAt"` + } `json:"user"` } - body, err := json.Marshal(payload) + // Execute the GraphQL query. + err := c.api.Do(query, variables, &response) if err != nil { - return 0, err - } - - var resp struct { - Data struct { - User struct { - CreatedAt string `json:"createdAt"` - } `json:"user"` - } `json:"data"` - } - if err := c.api.Post("graphql", bytes.NewBuffer(body), &resp); err != nil { - return 0, errors.New(errors.GraphQLError, "failed to fetch user join date", err) + return 0, errors.New(errors.NetworkError, "failed to fetch user's join date", err) } // Parse the join date - joinDate, err := time.Parse(time.RFC3339, resp.Data.User.CreatedAt) - if err != nil { - return 0, errors.New(errors.ValidationError, "failed to parse join date", err) + joinYear := response.User.CreatedAt.Year() + if joinYear == 0 { + return 0, errors.New(errors.ValidationError, "invalid join date received from GitHub API", nil) } - return joinDate.Year(), nil + return joinYear, nil } diff --git a/github/client_test.go b/github/client_test.go index 5587783..1d822ad 100644 --- a/github/client_test.go +++ b/github/client_test.go @@ -1,98 +1,50 @@ package github import ( - "encoding/json" - "io" "testing" + "time" "github.com/github/gh-skyline/errors" + "github.com/github/gh-skyline/testutil/mocks" + "github.com/github/gh-skyline/types" ) -type MockAPIClient struct { - GetFunc func(path string, response interface{}) error - PostFunc func(path string, body io.Reader, response interface{}) error -} - -func (m *MockAPIClient) Get(path string, response interface{}) error { - return m.GetFunc(path, response) -} - -func (m *MockAPIClient) Post(path string, body io.Reader, response interface{}) error { - return m.PostFunc(path, body, response) -} - -// mockAPIClient implements APIClient for testing -type mockAPIClient struct { - getResponse string - postResponse string - shouldError bool -} - -func (m *mockAPIClient) Get(_ string, response interface{}) error { - if m.shouldError { - return errors.New(errors.NetworkError, "mock error", nil) - } - return json.Unmarshal([]byte(m.getResponse), response) -} - -func (m *mockAPIClient) Post(_ string, _ io.Reader, response interface{}) error { - if m.shouldError { - return errors.New(errors.NetworkError, "mock error", nil) - } - return json.Unmarshal([]byte(m.postResponse), response) -} - -func TestNewClient(t *testing.T) { - mock := &mockAPIClient{} - client := NewClient(mock) - if client == nil { - t.Fatal("NewClient returned nil") - } - if client.api != mock { - t.Error("NewClient did not set api client correctly") - } -} - func TestGetAuthenticatedUser(t *testing.T) { tests := []struct { name string - response string - shouldError bool + mockResponse string + mockError error expectedUser string expectedError bool }{ { name: "successful response", - response: `{"login": "testuser"}`, + mockResponse: "testuser", expectedUser: "testuser", expectedError: false, }, { name: "empty username", - response: `{"login": ""}`, + mockResponse: "", expectedError: true, }, { name: "network error", - shouldError: true, + mockError: errors.New(errors.NetworkError, "network error", nil), expectedError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - mock := &mockAPIClient{ - getResponse: tt.response, - shouldError: tt.shouldError, - } - client := NewClient(mock) + client := NewClient(&mocks.MockGitHubClient{ + Username: tt.mockResponse, + Err: tt.mockError, + }) user, err := client.GetAuthenticatedUser() - if tt.expectedError && err == nil { - t.Error("expected error but got none") - } - if !tt.expectedError && err != nil { - t.Errorf("unexpected error: %v", err) + if (err != nil) != tt.expectedError { + t.Errorf("expected error: %v, got: %v", tt.expectedError, err) } if user != tt.expectedUser { t.Errorf("expected user %q, got %q", tt.expectedUser, user) @@ -101,123 +53,153 @@ func TestGetAuthenticatedUser(t *testing.T) { } } -func TestFetchContributions(t *testing.T) { +func TestGetUserJoinYear(t *testing.T) { tests := []struct { name string username string - year int - response string - shouldError bool + mockResponse time.Time + mockError error + expectedYear int expectedError bool }{ { - name: "successful response", - username: "testuser", - year: 2023, - response: `{"data":{"user":{"login":"testuser","contributionsCollection":{"contributionCalendar":{"totalContributions":100,"weeks":[]}}}}}`, + name: "successful response", + username: "testuser", + mockResponse: time.Date(2015, 1, 1, 0, 0, 0, 0, time.UTC), + expectedYear: 2015, + expectedError: false, }, { name: "empty username", username: "", - year: 2023, - expectedError: true, - }, - { - name: "invalid year", - username: "testuser", - year: 2007, expectedError: true, }, { name: "network error", username: "testuser", - year: 2023, - shouldError: true, - expectedError: true, - }, - { - name: "user not found", - username: "testuser", - year: 2023, - response: `{"data":{"user":{"login":""}}}`, + mockError: errors.New(errors.NetworkError, "network error", nil), expectedError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - mock := &mockAPIClient{ - postResponse: tt.response, - shouldError: tt.shouldError, - } - client := NewClient(mock) - - resp, err := client.FetchContributions(tt.username, tt.year) - if tt.expectedError && err == nil { - t.Error("expected error but got none") + client := NewClient(&mocks.MockGitHubClient{ + JoinYear: tt.expectedYear, + Err: tt.mockError, + }) + + year, err := client.GetUserJoinYear(tt.username) + if (err != nil) != tt.expectedError { + t.Errorf("expected error: %v, got: %v", tt.expectedError, err) } - if !tt.expectedError && err != nil { - t.Errorf("unexpected error: %v", err) - } - if !tt.expectedError && resp == nil { - t.Error("expected response but got nil") + if !tt.expectedError && year != tt.expectedYear { + t.Errorf("expected year %d, got %d", tt.expectedYear, year) } }) } } -func TestGetUserJoinYear(t *testing.T) { +func TestFetchContributions(t *testing.T) { + mockContributions := &types.ContributionsResponse{ + User: struct { + Login string `json:"login"` + ContributionsCollection struct { + ContributionCalendar struct { + TotalContributions int `json:"totalContributions"` + Weeks []struct { + ContributionDays []types.ContributionDay `json:"contributionDays"` + } `json:"weeks"` + } `json:"contributionCalendar"` + } `json:"contributionsCollection"` + }{ + Login: "chrisreddington", + ContributionsCollection: struct { + ContributionCalendar struct { + TotalContributions int `json:"totalContributions"` + Weeks []struct { + ContributionDays []types.ContributionDay `json:"contributionDays"` + } `json:"weeks"` + } `json:"contributionCalendar"` + }{ + ContributionCalendar: struct { + TotalContributions int `json:"totalContributions"` + Weeks []struct { + ContributionDays []types.ContributionDay `json:"contributionDays"` + } `json:"weeks"` + }{ + TotalContributions: 100, + Weeks: []struct { + ContributionDays []types.ContributionDay `json:"contributionDays"` + }{ + { + ContributionDays: []types.ContributionDay{ + { + ContributionCount: 5, + Date: "2023-01-01", + }, + }, + }, + }, + }, + }, + }, + } + tests := []struct { name string username string - response string - shouldError bool - expectedYear int + year int + mockResponse *types.ContributionsResponse + mockError error expectedError bool }{ { name: "successful response", username: "testuser", - response: `{"data":{"user":{"createdAt":"2015-01-01T00:00:00Z"}}}`, - expectedYear: 2015, + year: 2023, + mockResponse: mockContributions, expectedError: false, }, { name: "empty username", username: "", + year: 2023, expectedError: true, }, { - name: "network error", + name: "invalid year", username: "testuser", - shouldError: true, + year: 2007, expectedError: true, }, { - name: "invalid date format", + name: "network error", username: "testuser", - response: `{"data":{"user":{"createdAt":"invalid-date"}}}`, + year: 2023, + mockError: errors.New(errors.NetworkError, "network error", nil), expectedError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - mock := &mockAPIClient{ - postResponse: tt.response, - shouldError: tt.shouldError, - } - client := NewClient(mock) + client := NewClient(&mocks.MockGitHubClient{ + Username: tt.username, + MockData: tt.mockResponse, + Err: tt.mockError, + }) - joinYear, err := client.GetUserJoinYear(tt.username) - if tt.expectedError && err == nil { - t.Error("expected error but got none") - } - if !tt.expectedError && err != nil { - t.Errorf("unexpected error: %v", err) + resp, err := client.FetchContributions(tt.username, tt.year) + if (err != nil) != tt.expectedError { + t.Errorf("expected error: %v, got: %v", tt.expectedError, err) } - if joinYear != tt.expectedYear { - t.Errorf("expected year %d, got %d", tt.expectedYear, joinYear) + if !tt.expectedError { + if resp == nil { + t.Error("expected response but got nil") + } else if resp.User.Login != "testuser" { + t.Errorf("expected user testuser, got %s", resp.User.Login) + } } }) } diff --git a/go.mod b/go.mod index 50dfda7..30744f5 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,7 @@ require ( github.com/muesli/termenv v0.15.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/thlib/go-timezone-local v0.0.3 // indirect + github.com/thlib/go-timezone-local v0.0.6 // indirect golang.org/x/image v0.23.0 // indirect golang.org/x/sys v0.28.0 // indirect golang.org/x/term v0.27.0 // indirect diff --git a/go.sum b/go.sum index 35d856f..777916e 100644 --- a/go.sum +++ b/go.sum @@ -48,8 +48,8 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/thlib/go-timezone-local v0.0.3 h1:ie5XtZWG5lQ4+1MtC5KZ/FeWlOKzW2nPoUnXYUbV/1s= -github.com/thlib/go-timezone-local v0.0.3/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI= +github.com/thlib/go-timezone-local v0.0.6 h1:Ii3QJ4FhosL/+eCZl6Hsdr4DDU4tfevNoV83yAEo2tU= +github.com/thlib/go-timezone-local v0.0.6/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI= golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/main.go b/main.go index e815810..a5a0cdc 100644 --- a/main.go +++ b/main.go @@ -45,6 +45,7 @@ var ( full bool debug bool web bool + artOnly bool output string // new output path flag rootCmd = &cobra.Command{ @@ -106,6 +107,7 @@ func init() { 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)") } @@ -122,7 +124,8 @@ func formatYearRange(startYear, endYear int) string { if startYear == endYear { return fmt.Sprintf("%d", startYear) } - return fmt.Sprintf("%02d-%02d", startYear%100, endYear%100) + // 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 @@ -176,7 +179,7 @@ func generateSkyline(startYear, endYear int, targetUser string, full bool) error allContributions = append(allContributions, contributions) // Generate ASCII art for each year - asciiArt, err := ascii.GenerateASCII(contributions, targetUser, year, year == startYear) + 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 @@ -190,8 +193,11 @@ func generateSkyline(startYear, endYear int, targetUser string, full bool) error lines := strings.Split(asciiArt, "\n") gridStart := 0 for i, line := range lines { - if strings.Contains(line, string(ascii.EmptyBlock)) || - strings.Contains(line, string(ascii.FoundationLow)) { + 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 } @@ -202,14 +208,18 @@ func generateSkyline(startYear, endYear int, targetUser string, full bool) error } } - // Generate filename - outputPath := generateOutputFilename(targetUser, startYear, endYear) + 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) + // 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 stl.GenerateSTLRange(allContributions, outputPath, targetUser, startYear, endYear) + + return nil } // Variable for client initialization - allows for testing @@ -217,22 +227,22 @@ var initializeGitHubClient = defaultGitHubClient // defaultGitHubClient is the default implementation of client initialization func defaultGitHubClient() (*github.Client, error) { - apiClient, err := api.DefaultRESTClient() + apiClient, err := api.DefaultGraphQLClient() if err != nil { - return nil, fmt.Errorf("failed to create REST client: %w", err) + 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) { - resp, err := client.FetchContributions(username, year) + 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 := resp.Data.User.ContributionsCollection.ContributionCalendar.Weeks + weeks := response.User.ContributionsCollection.ContributionCalendar.Weeks contributionGrid := make([][]types.ContributionDay, len(weeks)) for i, week := range weeks { contributionGrid[i] = week.ContributionDays diff --git a/main_test.go b/main_test.go index ec9d22d..242d6ef 100644 --- a/main_test.go +++ b/main_test.go @@ -1,142 +1,25 @@ package main import ( - "io" "testing" - "time" - "encoding/json" "fmt" - "strings" "github.com/github/gh-skyline/github" - "github.com/github/gh-skyline/types" + "github.com/github/gh-skyline/testutil/fixtures" + "github.com/github/gh-skyline/testutil/mocks" ) -// MockGitHubClient implements the github.APIClient interface -type MockGitHubClient struct { - username string - joinYear int - shouldError bool // Add error flag -} - -// Get implements the APIClient interface -func (m *MockGitHubClient) Get(_ string, _ interface{}) error { - return nil -} - -// Post implements the APIClient interface -func (m *MockGitHubClient) Post(path string, body io.Reader, response interface{}) error { - if path == "graphql" { - // Read the request body to determine which GraphQL query is being made - bodyBytes, _ := io.ReadAll(body) - bodyStr := string(bodyBytes) - - if strings.Contains(bodyStr, "UserJoinDate") { - // Handle user join date query - resp := response.(*struct { - Data struct { - User struct { - CreatedAt string `json:"createdAt"` - } `json:"user"` - } `json:"data"` - }) - resp.Data.User.CreatedAt = time.Date(m.joinYear, 1, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339) - return nil - } - - if strings.Contains(bodyStr, "ContributionGraph") { - // Handle contribution graph query (existing logic) - return json.Unmarshal(contributionResponse(m.username), response) - } - } - return nil -} - -// Helper function to generate mock contribution response -func contributionResponse(username string) []byte { - response := fmt.Sprintf(`{ - "data": { - "user": { - "login": "%s", - "contributionsCollection": { - "contributionCalendar": { - "totalContributions": 1, - "weeks": [ - { - "contributionDays": [ - { - "contributionCount": 1, - "date": "2024-01-01" - } - ] - } - ] - } - } - } - } - }`, username) - return []byte(response) -} - -// GetAuthenticatedUser returns the authenticated user's username or an error -// if the mock client is set to error or the username is not set. -func (m *MockGitHubClient) GetAuthenticatedUser() (string, error) { - // Return error if shouldError is true - if m.shouldError { - return "", fmt.Errorf("mock client error") - } - // Validate username is not empty - if m.username == "" { - return "", fmt.Errorf("mock username not set") - } - return m.username, nil -} - -// GetUserJoinYear implements the GitHubClientInterface. -// It returns the year the user joined GitHub. -func (m *MockGitHubClient) GetUserJoinYear(_ string) (int, error) { - return m.joinYear, nil -} - -// FetchContributions mocks fetching GitHub contributions for a user -// in a given year, returning minimal valid data. -func (m *MockGitHubClient) FetchContributions(username string, year int) (*types.ContributionsResponse, error) { - // Return minimal valid response - resp := &types.ContributionsResponse{} - resp.Data.User.Login = username - // Add a single week with a single day for minimal valid data - week := struct { - ContributionDays []types.ContributionDay `json:"contributionDays"` - }{ - ContributionDays: []types.ContributionDay{ - { - ContributionCount: 1, - Date: time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC).Format("2006-01-02"), - }, - }, - } - resp.Data.User.ContributionsCollection.ContributionCalendar.Weeks = []struct { - ContributionDays []types.ContributionDay `json:"contributionDays"` - }{week} - return resp, nil -} - // MockBrowser implements the Browser interface type MockBrowser struct { - LastURL string - ShouldError bool + LastURL string + Err error } // Browse implements the Browser interface -// Changed from pointer receiver to value receiver func (m *MockBrowser) Browse(url string) error { m.LastURL = url - if m.ShouldError { - return fmt.Errorf("mock browser error") - } - return nil + return m.Err } func TestFormatYearRange(t *testing.T) { @@ -156,7 +39,7 @@ func TestFormatYearRange(t *testing.T) { name: "different years", startYear: 2020, endYear: 2024, - want: "20-24", + want: "2020-24", }, } @@ -190,7 +73,7 @@ func TestGenerateOutputFilename(t *testing.T) { user: "testuser", startYear: 2020, endYear: 2024, - want: "testuser-20-24-github-skyline.stl", + want: "testuser-2020-24-github-skyline.stl", }, } @@ -313,7 +196,7 @@ func TestGenerateSkyline(t *testing.T) { endYear int targetUser string full bool - mockClient *MockGitHubClient + mockClient *mocks.MockGitHubClient wantErr bool }{ { @@ -322,9 +205,10 @@ func TestGenerateSkyline(t *testing.T) { endYear: 2024, targetUser: "testuser", full: false, - mockClient: &MockGitHubClient{ - username: "testuser", - joinYear: 2020, + mockClient: &mocks.MockGitHubClient{ + Username: "testuser", + JoinYear: 2020, + MockData: fixtures.GenerateContributionsResponse("testuser", 2024), }, wantErr: false, }, @@ -334,21 +218,23 @@ func TestGenerateSkyline(t *testing.T) { endYear: 2024, targetUser: "testuser", full: false, - mockClient: &MockGitHubClient{ - username: "testuser", - joinYear: 2020, + mockClient: &mocks.MockGitHubClient{ + Username: "testuser", + JoinYear: 2020, + MockData: fixtures.GenerateContributionsResponse("testuser", 2024), }, wantErr: false, }, { name: "full range", - startYear: 2020, + startYear: 2008, endYear: 2024, targetUser: "testuser", full: true, - mockClient: &MockGitHubClient{ - username: "testuser", - joinYear: 2020, + mockClient: &mocks.MockGitHubClient{ + Username: "testuser", + JoinYear: 2008, + MockData: fixtures.GenerateContributionsResponse("testuser", 2024), }, wantErr: false, }, @@ -374,23 +260,22 @@ func TestOpenGitHubProfile(t *testing.T) { tests := []struct { name string targetUser string - mockClient *MockGitHubClient + mockClient *mocks.MockGitHubClient wantURL string wantErr bool }{ { name: "specific user", targetUser: "testuser", - mockClient: &MockGitHubClient{}, + mockClient: &mocks.MockGitHubClient{}, wantURL: "https://github.com/testuser", wantErr: false, }, { name: "authenticated user", targetUser: "", - mockClient: &MockGitHubClient{ - username: "authuser", - shouldError: false, + mockClient: &mocks.MockGitHubClient{ + Username: "authuser", }, wantURL: "https://github.com/authuser", wantErr: false, @@ -398,9 +283,8 @@ func TestOpenGitHubProfile(t *testing.T) { { name: "client error", targetUser: "", - mockClient: &MockGitHubClient{ - username: "", - shouldError: true, + mockClient: &mocks.MockGitHubClient{ + Err: fmt.Errorf("mock error"), }, wantErr: true, }, @@ -408,8 +292,10 @@ func TestOpenGitHubProfile(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Create MockBrowser and call openGitHubProfile - mockBrowser := &MockBrowser{ShouldError: tt.wantErr} + mockBrowser := &MockBrowser{} + if tt.wantErr { + mockBrowser.Err = fmt.Errorf("mock error") + } err := openGitHubProfile(tt.targetUser, tt.mockClient, mockBrowser) if (err != nil) != tt.wantErr { diff --git a/testutil/fixtures/github.go b/testutil/fixtures/github.go new file mode 100644 index 0000000..491baba --- /dev/null +++ b/testutil/fixtures/github.go @@ -0,0 +1,43 @@ +// Package fixtures provides test utilities and mock data generators +// for testing the gh-skyline application. +package fixtures + +import ( + "time" + + "github.com/github/gh-skyline/types" +) + +// GenerateContributionsResponse creates a mock contributions response +func GenerateContributionsResponse(username string, year int) *types.ContributionsResponse { + response := &types.ContributionsResponse{} + response.User.Login = username + response.User.ContributionsCollection.ContributionCalendar.TotalContributions = 100 + + // Create sample weeks with contribution days + weeks := make([]struct { + ContributionDays []types.ContributionDay `json:"contributionDays"` + }, 52) + + for i := range weeks { + days := make([]types.ContributionDay, 7) + for j := range days { + days[j] = types.ContributionDay{ + ContributionCount: (i + j) % 10, + Date: time.Date(year, 1, 1+i*7+j, 0, 0, 0, 0, time.UTC).Format("2006-01-02"), + } + } + weeks[i].ContributionDays = days + } + + response.User.ContributionsCollection.ContributionCalendar.Weeks = weeks + return response +} + +// CreateMockContributionDay creates a mock contribution day +func CreateMockContributionDay(date time.Time, count int) types.ContributionDay { + return types.ContributionDay{ + ContributionCount: count, + Date: date.Format("2006-01-02"), + } +} diff --git a/testutil/mocks/github.go b/testutil/mocks/github.go new file mode 100644 index 0000000..b03409e --- /dev/null +++ b/testutil/mocks/github.go @@ -0,0 +1,79 @@ +// Package mocks provides mock implementations of interfaces used in testing +package mocks + +import ( + "fmt" + "time" + + "github.com/github/gh-skyline/testutil/fixtures" + "github.com/github/gh-skyline/types" +) + +// MockGitHubClient implements both GitHubClientInterface and APIClient interfaces +type MockGitHubClient struct { + Username string + JoinYear int + MockData *types.ContributionsResponse + Response interface{} // Generic response field for testing + Err error // Error to return if needed +} + +// GetAuthenticatedUser implements GitHubClientInterface +func (m *MockGitHubClient) GetAuthenticatedUser() (string, error) { + if m.Err != nil { + return "", m.Err + } + if m.Username == "" { + return "", fmt.Errorf("mock username not set") + } + return m.Username, nil +} + +// GetUserJoinYear implements GitHubClientInterface +func (m *MockGitHubClient) GetUserJoinYear(_ string) (int, error) { + if m.Err != nil { + return 0, m.Err + } + if m.JoinYear == 0 { + return 0, fmt.Errorf("mock join year not set") + } + return m.JoinYear, nil +} + +// FetchContributions implements GitHubClientInterface +func (m *MockGitHubClient) FetchContributions(username string, year int) (*types.ContributionsResponse, error) { + if m.Err != nil { + return nil, m.Err + } + // Always return generated mock data with valid contributions + return fixtures.GenerateContributionsResponse(username, year), nil +} + +// Do implements APIClient +func (m *MockGitHubClient) Do(_ string, _ map[string]interface{}, response interface{}) error { + if m.Err != nil { + return m.Err + } + + switch v := response.(type) { + case *struct { + Viewer struct { + Login string `json:"login"` + } `json:"viewer"` + }: + v.Viewer.Login = m.Username + case *struct { + User struct { + CreatedAt time.Time `json:"createdAt"` + } `json:"user"` + }: + if m.JoinYear > 0 { + v.User.CreatedAt = time.Date(m.JoinYear, 1, 1, 0, 0, 0, 0, time.UTC) + } + case *types.ContributionsResponse: + // Always use generated mock data instead of empty response + mockResp := fixtures.GenerateContributionsResponse(m.Username, time.Now().Year()) + *v = *mockResp + } + return nil +} diff --git a/types/types.go b/types/types.go index f70f84d..13f6dd6 100644 --- a/types/types.go +++ b/types/types.go @@ -9,9 +9,8 @@ import ( ) // ContributionDay represents a single day of GitHub contributions. -// It contains the number of contributions made on a specific date. type ContributionDay struct { - ContributionCount int + ContributionCount int `json:"contributionCount"` Date string `json:"date"` } @@ -37,22 +36,19 @@ func (c ContributionDay) Validate() error { return nil } -// ContributionsResponse represents the GitHub GraphQL API response structure -// for fetching user contributions data. +// ContributionsResponse represents the contribution data returned by the GitHub API. type ContributionsResponse struct { - Data struct { - User struct { - Login string - ContributionsCollection struct { - ContributionCalendar struct { - TotalContributions int `json:"totalContributions"` - Weeks []struct { - ContributionDays []ContributionDay `json:"contributionDays"` - } `json:"weeks"` - } `json:"contributionCalendar"` - } `json:"contributionsCollection"` - } `json:"user"` - } `json:"data"` + User struct { + Login string `json:"login"` + ContributionsCollection struct { + ContributionCalendar struct { + TotalContributions int `json:"totalContributions"` + Weeks []struct { + ContributionDays []ContributionDay `json:"contributionDays"` + } `json:"weeks"` + } `json:"contributionCalendar"` + } `json:"contributionsCollection"` + } `json:"user"` } // Point3D represents a point in 3D space using float64 for accuracy in calculations. diff --git a/types/types_test.go b/types/types_test.go index 29a9be2..ccf1798 100644 --- a/types/types_test.go +++ b/types/types_test.go @@ -44,23 +44,21 @@ func TestContributionDaySerialization(t *testing.T) { // is properly parsed with nested fields. func TestContributionsResponseParsing(t *testing.T) { sampleResponse := `{ - "data": { - "user": { - "login": "testuser", - "contributionsCollection": { - "contributionCalendar": { - "totalContributions": 100, - "weeks": [ - { - "contributionDays": [ - { - "contributionCount": 5, - "date": "2024-03-21" - } - ] - } - ] - } + "user": { + "login": "testuser", + "contributionsCollection": { + "contributionCalendar": { + "totalContributions": 100, + "weeks": [ + { + "contributionDays": [ + { + "contributionCount": 5, + "date": "2024-03-21" + } + ] + } + ] } } } @@ -75,12 +73,12 @@ func TestContributionsResponseParsing(t *testing.T) { expectedUsername := "testuser" expectedTotalContributions := 100 - if parsedResponse.Data.User.Login != expectedUsername { - t.Errorf("username mismatch: got %q, want %q", parsedResponse.Data.User.Login, expectedUsername) + if parsedResponse.User.Login != expectedUsername { + t.Errorf("username mismatch: got %q, want %q", parsedResponse.User.Login, expectedUsername) } - if parsedResponse.Data.User.ContributionsCollection.ContributionCalendar.TotalContributions != expectedTotalContributions { + if parsedResponse.User.ContributionsCollection.ContributionCalendar.TotalContributions != expectedTotalContributions { t.Errorf("total contributions mismatch: got %d, want %d", - parsedResponse.Data.User.ContributionsCollection.ContributionCalendar.TotalContributions, + parsedResponse.User.ContributionsCollection.ContributionCalendar.TotalContributions, expectedTotalContributions) } }