diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f49fea5..ac35121 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -3,7 +3,7 @@ { "name": "Go", // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile - "image": "mcr.microsoft.com/devcontainers/go:1-1.23-bookworm", + "image": "mcr.microsoft.com/devcontainers/go:1-1.24-bookworm", "customizations": { "vscode": { "extensions": [ @@ -14,7 +14,6 @@ "github.copilot-workspace", "GitHub.vscode-pull-request-github", "GitHub.remotehub", - "GitHub.vscode-codeql", "golang.Go" ] } @@ -29,13 +28,13 @@ "features": { "ghcr.io/devcontainers/features/github-cli:1": {} - } + }, // Use 'forwardPorts' to make a list of ports inside the container available locally. // "forwardPorts": [], // Use 'postCreateCommand' to run commands after the container is created. - // "postCreateCommand": "go version", + "postCreateCommand": "go install -v golang.org/x/tools/cmd/goimports@latest" // Configure tool-specific properties. // "customizations": {}, diff --git a/.github/ISSUE_TEMPLATE/1-feature-request.yml b/.github/ISSUE_TEMPLATE/1-feature-request.yml new file mode 100644 index 0000000..ae07e41 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-feature-request.yml @@ -0,0 +1,53 @@ +name: Feature Request +description: Propose a new feature or enhancement +title: "[Feature]: " +labels: ["enhancement", "triage"] +assignees: + - chrisreddington +body: + - type: markdown + attributes: + value: | + Thank you for taking the time to submit a feature request! Please [check for existing requests](https://github.com/github/gh-skyline/issues) before submitting. If you find one, feel free to add more information or give a 👍 to the original issue. + + - type: textarea + id: description + attributes: + label: Description + description: Describe the feature or enhancement you're proposing + placeholder: Explain the current situation and why this change would be beneficial + validations: + required: true + + - type: textarea + id: requirements + attributes: + label: Requirements + description: List the specific requirements or changes needed + placeholder: | + - Requirement 1 + - Requirement 2 + - Requirement 3 + validations: + required: true + + - type: textarea + id: definition-of-done + attributes: + label: Definition of Done + description: What criteria need to be met for this feature to be considered complete? + value: | + - [ ] Criterion 1 + - [ ] Criterion 2 + - [ ] Criterion 3 + - [ ] Relevant documentation is updated + - [ ] Unit tests are updated/written and passing + validations: + required: true + + - type: textarea + id: additional-notes + attributes: + label: Additional Notes + description: Any other context, implementation suggestions, or considerations + placeholder: Add any other relevant information here diff --git a/.github/ISSUE_TEMPLATE/2-bug-report.yml b/.github/ISSUE_TEMPLATE/2-bug-report.yml new file mode 100644 index 0000000..ceb3a0c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2-bug-report.yml @@ -0,0 +1,42 @@ +name: Bug Report +description: File a bug report. +title: "[Bug]: " +labels: ["bug", "triage"] +assignees: + - chrisreddington +body: + - type: markdown + attributes: + value: | + Thank you for taking the time to report this issue! Please [check for existing bugs](https://github.com/github/gh-skyline/issues) before submitting. If you find one, feel free to add more information or give a 👍 to the original issue. + - type: input + id: gh-cli-version + attributes: + label: GitHub CLI Version + description: What version of the GitHub CLI are you using? + placeholder: 2.0.0 + - type: input + id: gh-skyline-version + attributes: + label: gh-skyline Version + description: What version of gh-skyline are you using? + placeholder: 0.0.3 + - type: dropdown + id: os + attributes: + label: What Operating System are you seeing the problem on? + multiple: true + options: + - Linux + - macOS + - Windows + - Other + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Also tell us, what did you expect to happen? + placeholder: Tell us what you see! + value: "A bug happened!" + validations: + required: true diff --git a/.github/dependabot.yml b/.github/dependabot.yml index bdd8483..2d76a1b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -12,7 +12,15 @@ updates: allow: - dependency-type: "direct" - dependency-type: "indirect" + groups: + go-dependencies: + patterns: + - "*" - package-ecosystem: "github-actions" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "weekly" + groups: + github-actions: + patterns: + - "*" diff --git a/.github/linters/.golangci.yml b/.github/linters/.golangci.yml index 00aaeb7..ca09278 100644 --- a/.github/linters/.golangci.yml +++ b/.github/linters/.golangci.yml @@ -8,11 +8,25 @@ run: # Allow multiple packages allow-separate-packages: true + tests: true + +linters-settings: + gosec: + excludes: + - G304 # File path provided as taint input + # Configure specific linters linters: enable: + - bodyclose + - copyloopvar + - durationcheck + - gocritic - gofmt + - gosec - govet + - ineffassign + - nilerr - revive - staticcheck 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/build.yml b/.github/workflows/build.yml index ff406a8..7a50f5d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,7 +9,6 @@ on: permissions: contents: read - pull-requests: write jobs: build: @@ -40,17 +39,11 @@ jobs: coverage.html coverage.txt - - name: Post Coverage Comment - if: github.event_name == 'pull_request' - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - const coverage = fs.readFileSync('coverage.txt', 'utf8'); - const comment = `### Code Coverage Report\n\`\`\`\n${coverage}\`\`\``; - github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: comment - }); + - name: Post Coverage Summary + run: | + { + echo "### Code Coverage Report" + echo '```' + cat coverage.txt + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index cf75908..4ccaf2f 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -26,10 +26,10 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.23" + go-version-file: go.mod - name: Run Super-Linter - uses: super-linter/super-linter/slim@e1cb86b6e8d119f789513668b4b30bf17fe1efe4 # v7.2.0 + uses: super-linter/super-linter/slim@4e8a7c2bf106c4c766c816b35ec612638dc9b6b2 # v7.3.0 env: VALIDATE_ALL_CODEBASE: true DEFAULT_BRANCH: "main" @@ -40,3 +40,4 @@ jobs: VALIDATE_JSON_PRETTIER: false LINTER_RULES_PATH: .github/linters GOLANGCI_LINT_CONFIG: .golangci.yml + VALIDATE_YAML_PRETTIER: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d93825..11eaa16 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,5 +15,9 @@ jobs: - uses: actions/checkout@v4 - uses: cli/gh-extension-precompile@561b19deda1228a0edf856c3325df87416f8c9bd # v2.0.0 with: + release_android: true + # For more information see: https://developer.android.com/tools/releases/platforms + # At the time of writing, 35 had just come out of beta and 34 seems to be the most stable. + android_sdk_version: 34 generate_attestations: true go_version_file: go.mod diff --git a/.golangci.yml b/.golangci.yml new file mode 120000 index 0000000..7292dd3 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1 @@ +.github/linters/.golangci.yml \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ee878d0 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,19 @@ +{ + // Use golangci-lint for linting + "go.lintTool": "golangci-lint", + + // Configure linter flags + "go.lintFlags": ["--fast"], + + "go.formatTool": "goimports", + "go.useLanguageServer": true, + "go.testOnSave": true, + + // Editor settings optimized for Go development + "[go]": { + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "always" + } + } +} diff --git a/CODEOWNERS b/CODEOWNERS index 820093c..de31019 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -2,4 +2,4 @@ # This repository is maintained by: -* @chrisreddington @leereilly @martinwoodward +* @chrisreddington @leereilly 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 7952ccc..c129177 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,9 @@ A GitHub CLI extension that generates 3D-printable STL files of your GitHub cont - Automatic authentication via GitHub CLI or specify a user - ASCII art loading preview of contribution data unique to each user and year -![Examples of gh-skyline being used in the terminal](https://github.com/user-attachments/assets/a69de393-64d1-4eba-9190-f6e2f81c5145) +| 3D Print | ASCII Art | +| ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| ![Example GitHub Skyline](https://github.com/user-attachments/assets/ed0fe34e-6825-4eb2-91d7-a0834966dc3a) | ![Example of gh-skyline and ASCII art in the terminal](https://github.com/user-attachments/assets/8ddda088-ac5a-4020-8ae0-ef0d4825f6a1) | ## Usage @@ -29,14 +31,21 @@ gh extension install github/gh-skyline You can run the `gh skyline` command with the following flags: -- `--user`: Specify the GitHub username. If not provided, the authenticated user is used. +- `-d`, `--debug`: Enable debug logging for more detailed output. + - Example: `gh skyline --debug` +- `-h`, `--help`: Show help for the command. + - Example: `gh skyline --help` +- `-f`, `--full`: Generate the contribution graph from the user's join year to the current year. + - Example: `gh skyline --full` +- `-o`, `--output`: Specify the output filename. If not provided, the default is `{username}-{year}-github-skyline.stl`. + - Example: `gh skyline --output my-skyline.stl` +- `-u`, `--user`: Specify the GitHub username. If not provided, the authenticated user is used. - Example: `gh skyline --user mona` -- `--year`: Specify the year or range of years for the skyline. Must be between 2008 and the current year. +- `-y`, `--year`: Specify the year or range of years for the skyline. Must be between 2008 and the current year. - Examples: `gh skyline --year 2020`, `gh skyline --year 2014-2024` -- `--full`: Generate the contribution graph from the user's join year to the current year. - - Example: `gh skyline --full` -- `--debug`: Enable debug logging for more detailed output. - - Example: `gh skyline --debug` +- `-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 @@ -70,13 +79,50 @@ 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 gh skyline --debug ``` -This will create a `{username}-{year}-github-skyline.stl` file in your current directory. +By default, the CLI will create a `{username}-{year}-github-skyline.stl` file in your current directory. You can specify a different filename using the `--output` flag. + +```bash +gh skyline --output my-skyline.stl +``` + +Open the GitHub profile for the authenticated user: + +```bash +gh skyline --web +``` + +Open the GitHub profile for a specific user: + +```bash +gh skyline --user mona --web +``` + +## ASCII Art + +The extension generates ASCII art in terminal while loading, a unique and fun way to vizualise your contribution data while you wait! 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. + +- `' '` 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) + +## 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 diff --git a/ascii/generator_test.go b/ascii/generator_test.go deleted file mode 100644 index a0ed580..0000000 --- a/ascii/generator_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package ascii - -import ( - "strings" - "testing" - - "github.com/github/gh-skyline/types" -) - -func TestGenerateASCII(t *testing.T) { - tests := []struct { - name string - grid [][]types.ContributionDay - user string - year int - includeHeader bool - wantErr bool - }{ - { - name: "empty grid", - grid: [][]types.ContributionDay{}, - user: "testuser", - year: 2023, - includeHeader: false, - wantErr: true, - }, - { - name: "valid grid", - 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) - if (err != nil) != tt.wantErr { - t.Errorf("GenerateASCII() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !tt.wantErr { - // Existing validation code... - if !strings.Contains(result, "testuser") { - t.Error("Generated ASCII should contain username") - } - if !strings.Contains(result, "2023") { - t.Error("Generated ASCII should contain year") - } - if !strings.Contains(result, string(EmptyBlock)) { - t.Error("Generated ASCII should contain empty blocks") - } - } - }) - } -} - -// Helper function to create test grid -func makeTestGrid(weeks, days int) [][]types.ContributionDay { - grid := make([][]types.ContributionDay, weeks) - for i := range grid { - grid[i] = make([]types.ContributionDay, days) - for j := range grid[i] { - grid[i][j] = types.ContributionDay{ContributionCount: i * j} - } - } - return grid -} - -func TestGetBlock(t *testing.T) { - tests := []struct { - name string - normalized float64 - dayIdx int - nonZeroIdx int - expectedRune rune - }{ - {"empty block", 0.0, 0, 1, EmptyBlock}, - {"single low block", 0.2, 0, 1, FoundationLow}, - {"single medium block", 0.5, 0, 1, FoundationMed}, - {"single high block", 0.8, 0, 1, FoundationHigh}, - {"foundation low", 0.2, 0, 2, FoundationLow}, - {"middle high", 0.8, 1, 3, MiddleHigh}, - {"top medium", 0.5, 2, 3, TopMed}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := getBlock(tt.normalized, tt.dayIdx, tt.nonZeroIdx) - if result != tt.expectedRune { - t.Errorf("getBlock(%f, %d, %d) = %c, want %c", - tt.normalized, tt.dayIdx, tt.nonZeroIdx, - result, tt.expectedRune) - } - }) - } -} 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/github/client_test.go b/github/client_test.go deleted file mode 100644 index 5587783..0000000 --- a/github/client_test.go +++ /dev/null @@ -1,224 +0,0 @@ -package github - -import ( - "encoding/json" - "io" - "testing" - - "github.com/github/gh-skyline/errors" -) - -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 - expectedUser string - expectedError bool - }{ - { - name: "successful response", - response: `{"login": "testuser"}`, - expectedUser: "testuser", - expectedError: false, - }, - { - name: "empty username", - response: `{"login": ""}`, - expectedError: true, - }, - { - name: "network error", - shouldError: true, - 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) - - 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 user != tt.expectedUser { - t.Errorf("expected user %q, got %q", tt.expectedUser, user) - } - }) - } -} - -func TestFetchContributions(t *testing.T) { - tests := []struct { - name string - username string - year int - response string - shouldError bool - expectedError bool - }{ - { - name: "successful response", - username: "testuser", - year: 2023, - response: `{"data":{"user":{"login":"testuser","contributionsCollection":{"contributionCalendar":{"totalContributions":100,"weeks":[]}}}}}`, - }, - { - 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":""}}}`, - 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") - } - if !tt.expectedError && err != nil { - t.Errorf("unexpected error: %v", err) - } - if !tt.expectedError && resp == nil { - t.Error("expected response but got nil") - } - }) - } -} - -func TestGetUserJoinYear(t *testing.T) { - tests := []struct { - name string - username string - response string - shouldError bool - expectedYear int - expectedError bool - }{ - { - name: "successful response", - username: "testuser", - response: `{"data":{"user":{"createdAt":"2015-01-01T00:00:00Z"}}}`, - expectedYear: 2015, - expectedError: false, - }, - { - name: "empty username", - username: "", - expectedError: true, - }, - { - name: "network error", - username: "testuser", - shouldError: true, - expectedError: true, - }, - { - name: "invalid date format", - username: "testuser", - response: `{"data":{"user":{"createdAt":"invalid-date"}}}`, - 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) - - 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) - } - if joinYear != tt.expectedYear { - t.Errorf("expected year %d, got %d", tt.expectedYear, joinYear) - } - }) - } -} diff --git a/go.mod b/go.mod index 3a61c55..e571982 100644 --- a/go.mod +++ b/go.mod @@ -1,30 +1,32 @@ module github.com/github/gh-skyline -go 1.22 - -toolchain go1.23.3 +go 1.24.1 require ( - github.com/cli/go-gh/v2 v2.11.1 + github.com/cli/go-gh/v2 v2.12.1 github.com/fogleman/gg v1.3.0 + github.com/spf13/cobra v1.9.1 ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/cli/browser v1.3.0 // indirect github.com/cli/safeexec v1.0.1 // indirect github.com/cli/shurcooL-graphql v0.0.4 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/henvic/httpretty v0.1.4 // indirect - github.com/kr/text v0.2.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kr/pretty v0.3.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/muesli/termenv v0.15.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/thlib/go-timezone-local v0.0.3 // indirect - golang.org/x/image v0.22.0 // indirect - golang.org/x/sys v0.27.0 // indirect - golang.org/x/term v0.26.0 // indirect - golang.org/x/text v0.20.0 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/thlib/go-timezone-local v0.0.6 // indirect + golang.org/x/image v0.27.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/term v0.32.0 // indirect + golang.org/x/text v0.25.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 4f9c6fb..61b5328 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,14 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/cli/go-gh/v2 v2.11.1 h1:amAyfqMWQTBdue8iTmDUegGZK7c8kk6WCxD9l/wLtGI= -github.com/cli/go-gh/v2 v2.11.1/go.mod h1:MeRoKzXff3ygHu7zP+NVTT+imcHW6p3tpuxHAzRM2xE= +github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= +github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= +github.com/cli/go-gh/v2 v2.12.1 h1:SVt1/afj5FRAythyMV3WJKaUfDNsxXTIe7arZbwTWKA= +github.com/cli/go-gh/v2 v2.12.1/go.mod h1:+5aXmEOJsH9fc9mBHfincDwnS02j2AIA/DsTH0Bk5uw= github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00= github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= github.com/cli/shurcooL-graphql v0.0.4 h1:6MogPnQJLjKkaXPyGqPRXOI2qCsQdqNfUY1QSJu2GuY= github.com/cli/shurcooL-graphql v0.0.4/go.mod h1:3waN4u02FiZivIV+p1y4d0Jo1jc6BViMA73C+sZo2fk= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -13,41 +16,50 @@ github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/henvic/httpretty v0.1.4 h1:Jo7uwIRWVFxkqOnErcoYfH90o3ddQyVrSANeS4cxYmU= github.com/henvic/httpretty v0.1.4/go.mod h1:Dn60sQTZfbt2dYsdUSNsCljyF4AfdqnuJFDLJA1I4AM= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= -github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/thlib/go-timezone-local v0.0.3 h1:ie5XtZWG5lQ4+1MtC5KZ/FeWlOKzW2nPoUnXYUbV/1s= -github.com/thlib/go-timezone-local v0.0.3/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI= -golang.org/x/image v0.22.0 h1:UtK5yLUzilVrkjMAZAZ34DXGpASN8i8pj8g+O+yd10g= -golang.org/x/image v0.22.0/go.mod h1:9hPFhljd4zZ1GNSIZJ49sqbp45GKK9t6w+iXvGqZUz4= +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.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= +golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU= -golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= -golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= -golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/ascii/block.go b/internal/ascii/block.go similarity index 100% rename from ascii/block.go rename to internal/ascii/block.go diff --git a/ascii/block_test.go b/internal/ascii/block_test.go similarity index 100% rename from ascii/block_test.go rename to internal/ascii/block_test.go diff --git a/ascii/generator.go b/internal/ascii/generator.go similarity index 90% rename from ascii/generator.go rename to internal/ascii/generator.go index cba3457..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 @@ -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/internal/ascii/generator_test.go b/internal/ascii/generator_test.go new file mode 100644 index 0000000..7cdaf8c --- /dev/null +++ b/internal/ascii/generator_test.go @@ -0,0 +1,181 @@ +package ascii + +import ( + "strings" + "testing" + + "github.com/github/gh-skyline/internal/types" +) + +func TestGenerateASCII(t *testing.T) { + tests := []struct { + name string + grid [][]types.ContributionDay + user string + year int + includeHeader bool + wantErr bool + }{ + { + name: "empty grid", + grid: [][]types.ContributionDay{}, + user: "testuser", + year: 2023, + includeHeader: false, + wantErr: true, + }, + { + name: "valid grid", + grid: makeTestGrid(3, 7), + user: "testuser", + year: 2023, + 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, tt.includeHeader) + if (err != nil) != tt.wantErr { + t.Errorf("GenerateASCII() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && tt.includeHeader { + // Existing validation code... + if !strings.Contains(result, "testuser") { + t.Error("Generated ASCII should contain username") + } + if !strings.Contains(result, "2023") { + t.Error("Generated ASCII should contain year") + } + 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") + } + } + }) + } +} + +// Helper function to create test grid +func makeTestGrid(weeks, days int) [][]types.ContributionDay { + grid := make([][]types.ContributionDay, weeks) + for i := range grid { + grid[i] = make([]types.ContributionDay, days) + for j := range grid[i] { + grid[i][j] = types.ContributionDay{ContributionCount: i * j} + } + } + return grid +} + +func TestGetBlock(t *testing.T) { + tests := []struct { + name string + normalized float64 + dayIdx int + nonZeroIdx int + expectedRune rune + }{ + {"empty block", 0.0, 0, 1, EmptyBlock}, + {"single low block", 0.2, 0, 1, FoundationLow}, + {"single medium block", 0.5, 0, 1, FoundationMed}, + {"single high block", 0.8, 0, 1, FoundationHigh}, + {"foundation low", 0.2, 0, 2, FoundationLow}, + {"middle high", 0.8, 1, 3, MiddleHigh}, + {"top medium", 0.5, 2, 3, TopMed}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getBlock(tt.normalized, tt.dayIdx, tt.nonZeroIdx) + if result != tt.expectedRune { + t.Errorf("getBlock(%f, %d, %d) = %c, want %c", + tt.normalized, tt.dayIdx, tt.nonZeroIdx, + result, tt.expectedRune) + } + }) + } +} + +// 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/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 57% rename from github/client.go rename to internal/github/client.go index ed11802..8694f44 100644 --- a/github/client.go +++ b/internal/github/client.go @@ -3,20 +3,16 @@ package github import ( - "bytes" - "encoding/json" "fmt" - "io" "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 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/internal/github/client_test.go b/internal/github/client_test.go new file mode 100644 index 0000000..c83ede9 --- /dev/null +++ b/internal/github/client_test.go @@ -0,0 +1,206 @@ +package github + +import ( + "testing" + "time" + + "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) { + tests := []struct { + name string + mockResponse string + mockError error + expectedUser string + expectedError bool + }{ + { + name: "successful response", + mockResponse: "testuser", + expectedUser: "testuser", + expectedError: false, + }, + { + name: "empty username", + mockResponse: "", + expectedError: true, + }, + { + name: "network error", + mockError: errors.New(errors.NetworkError, "network error", nil), + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := NewClient(&mocks.MockGitHubClient{ + Username: tt.mockResponse, + Err: tt.mockError, + }) + + user, err := client.GetAuthenticatedUser() + 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) + } + }) + } +} + +func TestGetUserJoinYear(t *testing.T) { + tests := []struct { + name string + username string + mockResponse time.Time + mockError error + expectedYear int + expectedError bool + }{ + { + 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: "", + expectedError: true, + }, + { + name: "network error", + username: "testuser", + mockError: errors.New(errors.NetworkError, "network error", nil), + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + 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 && year != tt.expectedYear { + t.Errorf("expected year %d, got %d", tt.expectedYear, year) + } + }) + } +} + +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 + year int + mockResponse *types.ContributionsResponse + mockError error + expectedError bool + }{ + { + name: "successful response", + username: "testuser", + year: 2023, + mockResponse: mockContributions, + 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, + mockError: errors.New(errors.NetworkError, "network error", nil), + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := NewClient(&mocks.MockGitHubClient{ + Username: tt.username, + MockData: tt.mockResponse, + Err: tt.mockError, + }) + + resp, err := client.FetchContributions(tt.username, tt.year) + if (err != nil) != tt.expectedError { + t.Errorf("expected error: %v, got: %v", tt.expectedError, err) + } + 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/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 97% rename from logger/logger.go rename to internal/logger/logger.go index 2922083..3e5575e 100644 --- a/logger/logger.go +++ b/internal/logger/logger.go @@ -45,7 +45,7 @@ func GetLogger() *Logger { once.Do(func() { instance = &Logger{ debug: log.New(os.Stdout, "DEBUG: ", log.Ldate|log.Ltime|log.Lshortfile), - info: log.New(os.Stdout, "INFO: ", log.Ldate|log.Ltime), + info: log.New(os.Stdout, "", 0), warning: log.New(os.Stdout, "WARNING: ", log.Ldate|log.Ltime), error: log.New(os.Stderr, "ERROR: ", log.Ldate|log.Ltime), level: INFO, 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 91% rename from stl/geometry/shapes.go rename to internal/stl/geometry/shapes.go index 53e6a6c..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. @@ -71,12 +71,12 @@ func createBox(x, y, z, width, height, depth float64) ([]types.Triangle, error) vertices := make([]types.Point3D, 8) // Pre-allocate vertices array quads := [6][4]int{ - {0, 1, 2, 3}, // front - {5, 4, 7, 6}, // back - {4, 0, 3, 7}, // left - {1, 5, 6, 2}, // right - {3, 2, 6, 7}, // top - {4, 5, 1, 0}, // bottom + {0, 3, 2, 1}, // front (viewed from front) + {5, 6, 7, 4}, // back (viewed from back) + {4, 7, 3, 0}, // left (viewed from left) + {1, 2, 6, 5}, // right (viewed from right) + {3, 7, 6, 2}, // top (viewed from top) + {4, 0, 1, 5}, // bottom (viewed from bottom) } // Fill vertices array 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/internal/stl/geometry/text.go b/internal/stl/geometry/text.go new file mode 100644 index 0000000..e29127a --- /dev/null +++ b/internal/stl/geometry/text.go @@ -0,0 +1,273 @@ +package geometry + +import ( + "fmt" + "image/png" + "os" + + "github.com/fogleman/gg" + "github.com/github/gh-skyline/internal/errors" + "github.com/github/gh-skyline/internal/types" +) + +const ( + baseWidthVoxelResolution = 2000 // Number of voxels across the skyline face + voxelDepth = 1.0 // Distance to come out of face + + logoScale = 0.4 // Percent + logoTopOffset = 0.15 // Percent + logoLeftOffset = 0.03 // Percent + + usernameFontSize = 120.0 + usernameJustification = "left" // "left", "center", "right" + usernameLeftOffset = 0.1 // Percent + + yearFontSize = 100.0 + yearJustification = "right" // "left", "center", "right" + yearLeftOffset = 0.97 // Percent +) + +// Create3DText generates 3D text geometry for the username and year. +func Create3DText(username string, year string, baseWidth float64, baseHeight float64) ([]types.Triangle, error) { + if username == "" { + username = "anonymous" + } + + usernameTriangles, err := renderText( + username, + usernameJustification, + usernameLeftOffset, + usernameFontSize, + baseWidth, + baseHeight, + ) + if err != nil { + return nil, err + } + + yearTriangles, err := renderText( + year, + yearJustification, + yearLeftOffset, + yearFontSize, + baseWidth, + baseHeight, + ) + if err != nil { + return nil, err + } + + return append(usernameTriangles, yearTriangles...), nil +} + +// renderText places text on the face of a skyline, offset from the left and vertically-aligned. +// The function takes the text to be displayed, offset from left, and font size. +// It returns an array of types.Triangle. +// +// Parameters: +// +// text (string): The text to be displayed on the skyline's front face. +// leftOffsetPercent (float64): The percentage distance from the left to start displaying the text. +// fontSize (float64): How large to make the text. Note: It scales with the baseWidthVoxelResolution. +// +// Returns: +// +// ([]types.Triangle, error): A slice of triangles representing text. +func renderText(text string, justification string, leftOffsetPercent float64, fontSize float64, baseWidth float64, baseHeight float64) ([]types.Triangle, error) { + // Create a rendering context for the face of the skyline + faceWidthRes := baseWidthVoxelResolution + faceHeightRes := int(float64(faceWidthRes) * baseHeight / baseWidth) + + // Create image representing the skyline face + dc := gg.NewContext(faceWidthRes, faceHeightRes) + dc.SetRGB(0, 0, 0) + dc.Clear() + dc.SetRGB(1, 1, 1) + + // Load font into context + fontPath, cleanup, err := writeTempFont(PrimaryFont) + if err != nil { + // Try fallback font + fontPath, cleanup, err = writeTempFont(FallbackFont) + if err != nil { + return nil, errors.New(errors.IOError, "failed to load any fonts", err) + } + } + if err := dc.LoadFontFace(fontPath, fontSize); err != nil { + return nil, errors.New(errors.IOError, "failed to load font", err) + } + + // Draw text on image at desired location + var triangles []types.Triangle + + // Convert justification to a number + var justificationPercent float64 + switch justification { + case "center": + justificationPercent = 0.5 + case "right": + justificationPercent = 1.0 + default: + justificationPercent = 0.0 + } + + dc.DrawStringAnchored( + text, + float64(faceWidthRes)*leftOffsetPercent, // Offset from right + float64(faceHeightRes)*0.5, // Offset from top + justificationPercent, // Justification (0.0=left, 0.5=center, 1.0=right) + 0.5, // Vertically aligned + ) + + // Convert context image pixels into voxels + for x := 0; x < faceWidthRes; x++ { + for y := 0; y < faceHeightRes; y++ { + if isPixelActive(dc, x, y) { + voxel, err := createVoxelOnFace( + float64(x), + float64(y), + voxelDepth, + baseWidth, + baseHeight, + ) + if err != nil { + return nil, errors.New(errors.STLError, "failed to create cube", err) + } + + triangles = append(triangles, voxel...) + } + } + } + + defer cleanup() + + return triangles, nil +} + +// createVoxelOnFace creates a voxel on the face of a skyline by generating a cube at the specified coordinates. +// The function takes in the x, y coordinates and height. +// It returns a slice of types.Triangle representing the cube and an error if the cube creation fails. +// +// Parameters: +// +// x (float64): The x-coordinate on the skyline face (left to right). +// y (float64): The y-coordinate on the skyline face (top to bottom). +// height (float64): Distance coming out of the face. +// +// Returns: +// +// ([]types.Triangle, error): A slice of triangles representing the cube and an error if any. +func createVoxelOnFace(x float64, y float64, height float64, baseWidth float64, baseHeight float64) ([]types.Triangle, error) { + // Mapping resolution + xResolution := float64(baseWidthVoxelResolution) + yResolution := xResolution * baseHeight / baseWidth + + // Pixel size + voxelSize := 1.0 + + // Scale coordinate to face resolution + x = (x / xResolution) * baseWidth + y = (y / yResolution) * baseHeight + voxelSizeX := (voxelSize / xResolution) * baseWidth + voxelSizeY := (voxelSize / yResolution) * baseHeight + + cube, err := CreateCube( + // Location (from top left corner of skyline face) + x, // x - Left to right + -height, // y - Negative comes out of face. Positive goes into face. + -voxelSizeY-y, // z - Bottom to top + + // Size + voxelSizeX, // x length - left to right from specified point + height, // thickness - distance coming out of face + voxelSizeY, // y length - bottom to top from specified point + ) + + return cube, err +} + +// GenerateImageGeometry creates 3D geometry from the embedded logo image. +func GenerateImageGeometry(baseWidth float64, baseHeight float64) ([]types.Triangle, error) { + // Get temporary image file + imgPath, cleanup, err := getEmbeddedImage() + if err != nil { + return nil, err + } + + defer cleanup() + + return renderImage( + imgPath, + logoScale, + voxelDepth, + logoLeftOffset, + logoTopOffset, + baseWidth, + baseHeight, + ) +} + +// renderImage generates 3D geometry for the given image configuration. +func renderImage(filePath string, scale float64, height float64, leftOffsetPercent float64, topOffsetPercent float64, baseWidth float64, baseHeight float64) ([]types.Triangle, error) { + + // Get voxel resolution of base face + faceWidthRes := baseWidthVoxelResolution + faceHeightRes := int(float64(faceWidthRes) * baseHeight / baseWidth) + + // Load image from file + reader, err := os.Open(filePath) + if err != nil { + return nil, errors.New(errors.IOError, "failed to open image", err) + } + defer func() { + if err := reader.Close(); err != nil { + closeErr := errors.New(errors.IOError, "failed to close reader", err) + // Log the error or handle it appropriately + fmt.Println(closeErr) + } + }() + img, err := png.Decode(reader) + if err != nil { + return nil, errors.New(errors.IOError, "failed to decode PNG", err) + } + + // Get image size + bounds := img.Bounds() + logoWidth := bounds.Max.X + logoHeight := bounds.Max.Y + + // Transfer image pixels onto face of skyline as voxels + var triangles []types.Triangle + for x := 0; x < logoWidth; x++ { + for y := logoHeight - 1; y >= 0; y-- { + // Get pixel color and alpha + r, _, _, a := img.At(x, y).RGBA() + + // If pixel is active (white) and not fully transparent, create a voxel + if a > 32768 && r > 32768 { + + voxel, err := createVoxelOnFace( + (leftOffsetPercent*float64(faceWidthRes))+float64(x)*scale, + (topOffsetPercent*float64(faceHeightRes))+float64(y)*scale, + height, + baseWidth, + baseHeight, + ) + + if err != nil { + return nil, errors.New(errors.STLError, "failed to create cube", err) + } + + triangles = append(triangles, voxel...) + } + } + } + + return triangles, nil +} + +// isPixelActive checks if a pixel is active (white) in the given context. +func isPixelActive(dc *gg.Context, x, y int) bool { + r, _, _, _ := dc.Image().At(x, y).RGBA() + return r > 32768 +} diff --git a/stl/geometry/text_test.go b/internal/stl/geometry/text_test.go similarity index 79% rename from stl/geometry/text_test.go rename to internal/stl/geometry/text_test.go index c1500a7..229487a 100644 --- a/stl/geometry/text_test.go +++ b/internal/stl/geometry/text_test.go @@ -13,10 +13,6 @@ import ( // TestCreate3DText verifies text geometry generation functionality. func TestCreate3DText(t *testing.T) { - // Skip tests if fonts are not available - if _, err := os.Stat(FallbackFont); err != nil { - t.Skip("Skipping text tests as font files are not available") - } t.Run("verify basic text mesh generation", func(t *testing.T) { triangles, err := Create3DText("test", "2023", 100.0, 5.0) @@ -63,47 +59,37 @@ func TestCreate3DText(t *testing.T) { // TestRenderText verifies internal text rendering functionality func TestRenderText(t *testing.T) { - // Skip if fonts not available - if _, err := os.Stat(FallbackFont); err != nil { - t.Skip("Skipping text tests as font files are not available") - } + t.Run("verify text renders", func(t *testing.T) { + triangles, err := renderText( + "Mona", // text + "left", // justification + 0.1, // leftOffsetPercent + 10.0, // fontSize + 200.0, // baseWidth + 10.0, // baseHeight + ) - t.Run("verify text config validation", func(t *testing.T) { - invalidConfig := textRenderConfig{ - renderConfig: renderConfig{ - startX: 0, - startY: 0, - startZ: 0, - voxelScale: 0, // Invalid scale - depth: 1, - }, - text: "test", - contextWidth: 100, - contextHeight: 100, - fontSize: 10, - } - _, err := renderText(invalidConfig) - if err == nil { - t.Error("Expected error for invalid text config") + if err != nil { + t.Fatalf("renderText failed: %v", err) + } + if len(triangles) == 0 { + t.Error("Expected non-zero triangles for rendered text") } }) } // TestRenderImage verifies internal image rendering functionality func TestRenderImage(t *testing.T) { - t.Run("verify invalid image path", func(t *testing.T) { - config := imageRenderConfig{ - renderConfig: renderConfig{ - startX: 0, - startY: 0, - startZ: 0, - voxelScale: 1, - depth: 1, - }, - imagePath: "nonexistent.png", - height: 10, - } - _, err := renderImage(config) + t.Run("verify invalid image", func(t *testing.T) { + _, err := renderImage( + "nonexistent.png", // filePath + 0.5, // scale + 100.0, // height + 0.1, // leftOffsetPercent + 0.1, // topOffsetPercent + 200.0, // baseWidth + 10.0, // baseHeight + ) if err == nil { t.Error("Expected error for invalid image path") } 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 89% rename from stl/stl.go rename to internal/stl/stl.go index df70aa7..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 ( @@ -38,6 +38,9 @@ const ( // - Attribute count: 2 bytes // Total: 50 bytes triangleSize = (12 * 4) + 2 + + // maxTriangleCount defines the maximum number of triangles allowed in an STL file. + maxTriangleCount = uint64(math.MaxUint32) ) // bufferWriter encapsulates common buffer writing operations @@ -140,11 +143,14 @@ func WriteSTLBinary(filename string, triangles []types.Triangle) error { return err } - triangleCount := len(triangles) - if triangleCount < 0 { - return errors.New(errors.ValidationError, "invalid number of triangles for STL format", nil) + triangleCount := uint64(len(triangles)) + if triangleCount > maxTriangleCount { + return errors.New(errors.ValidationError, "triangle count exceeds valid range for STL format", nil) } - if err := writeTriangleCount(writer, uint32(triangleCount)); err != nil { + + // Now safely convert to uint32 since we know it's in range + triangleCountUint32 := uint32(triangleCount) + if err := writeTriangleCount(writer, triangleCountUint32); err != nil { return err } 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/internal/testutil/fixtures/github.go b/internal/testutil/fixtures/github.go new file mode 100644 index 0000000..5498f9c --- /dev/null +++ b/internal/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/internal/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/internal/testutil/mocks/github.go b/internal/testutil/mocks/github.go new file mode 100644 index 0000000..8d6f906 --- /dev/null +++ b/internal/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/internal/testutil/fixtures" + "github.com/github/gh-skyline/internal/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/internal/types/types.go similarity index 85% rename from types/types.go rename to internal/types/types.go index f70f84d..13f6dd6 100644 --- a/types/types.go +++ b/internal/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/internal/types/types_test.go similarity index 93% rename from types/types_test.go rename to internal/types/types_test.go index 29a9be2..ccf1798 100644 --- a/types/types_test.go +++ b/internal/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) } } 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 5af66a3..ec1d8f9 100644 --- a/main.go +++ b/main.go @@ -1,214 +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 ( - "flag" - "fmt" + "context" "os" - "strconv" - "strings" - "time" - "github.com/cli/go-gh/v2/pkg/api" - "github.com/github/gh-skyline/ascii" - "github.com/github/gh-skyline/errors" - "github.com/github/gh-skyline/github" - "github.com/github/gh-skyline/logger" - "github.com/github/gh-skyline/stl" - "github.com/github/gh-skyline/types" + "github.com/github/gh-skyline/cmd" ) +type exitCode int + const ( - // githubLaunchYear represents the year GitHub was launched and contributions began - githubLaunchYear = 2008 - // outputFileFormat defines the format for the generated STL file - outputFileFormat = "%s-%s-github-skyline.stl" + exitOK exitCode = 0 + exitError exitCode = 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) - } - return fmt.Sprintf("%02d-%02d", startYear%100, endYear%100) -} - -// generateOutputFilename creates a consistent filename for the STL output -func generateOutputFilename(user string, startYear, endYear int) string { - yearStr := formatYearRange(startYear, endYear) - return fmt.Sprintf(outputFileFormat, user, yearStr) -} - -// 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) - 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 { - if strings.Contains(line, string(ascii.EmptyBlock)) || - strings.Contains(line, string(ascii.FoundationLow)) { - gridStart = i - break - } - } - // Print just the grid and user info - fmt.Println(strings.Join(lines[gridStart:], "\n")) - } - } - } - - // 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) -} - -// 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.DefaultRESTClient() - if err != nil { - return nil, fmt.Errorf("failed to create REST 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) - 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 - contributionGrid := make([][]types.ContributionDay, len(weeks)) - for i, week := range weeks { - contributionGrid[i] = week.ContributionDays - } - - return contributionGrid, nil -} - -// main is the entry point for the GitHub Skyline Generator. func main() { - yearRange := flag.String("year", fmt.Sprintf("%d", time.Now().Year()), "Year or year range (e.g., 2024 or 2014-2024)") - user := flag.String("user", "", "GitHub username (optional, defaults to authenticated user)") - full := flag.Bool("full", false, "Generate contribution graph from join year to current year") - debug := flag.Bool("debug", false, "Enable debug logging") - flag.Parse() - - log := logger.GetLogger() - if *debug { - log.SetLevel(logger.DEBUG) - if err := log.Debug("Debug logging enabled"); err != nil { - fmt.Fprintf(os.Stderr, "Failed to enable debug logging: %v\n", err) - os.Exit(1) - } - } - - // Parse year range - startYear, endYear, err := parseYearRange(*yearRange) - if err != nil { - if logErr := log.Error("Invalid year range: %v", err); logErr != nil { - fmt.Fprintf(os.Stderr, "Failed to log error: %v\n", logErr) - } - os.Exit(1) - } - - if err := generateSkyline(startYear, endYear, *user, *full); err != nil { - if logErr := log.Error("Failed to generate skyline: %v", err); logErr != nil { - fmt.Fprintf(os.Stderr, "Failed to log error: %v\n", logErr) - } - os.Exit(1) - } + code := start() + os.Exit(int(code)) } -// 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) -} +func start() exitCode { + exitCode := exitOK + ctx := context.Background() -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 err := cmd.Execute(ctx); err != nil { + exitCode = exitError } - if startYear > endYear { - return fmt.Errorf("start year cannot be after end year") - } - return nil + + return exitCode } diff --git a/main_test.go b/main_test.go deleted file mode 100644 index 41816e1..0000000 --- a/main_test.go +++ /dev/null @@ -1,339 +0,0 @@ -package main - -import ( - "io" - "testing" - "time" - - "encoding/json" - "fmt" - "strings" - - "github.com/github/gh-skyline/github" - "github.com/github/gh-skyline/types" -) - -// MockGitHubClient implements the github.APIClient interface -type MockGitHubClient struct { - username string - joinYear int -} - -// 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) -} - -func (m *MockGitHubClient) GetAuthenticatedUser() (string, error) { - return m.username, nil -} - -func (m *MockGitHubClient) GetUserJoinYear(_ string) (int, error) { - return m.joinYear, nil -} - -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 -} - -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: "20-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-20-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 *MockGitHubClient - wantErr bool - }{ - { - name: "single year", - startYear: 2024, - endYear: 2024, - targetUser: "testuser", - full: false, - mockClient: &MockGitHubClient{ - username: "testuser", - joinYear: 2020, - }, - wantErr: false, - }, - { - name: "year range", - startYear: 2020, - endYear: 2024, - targetUser: "testuser", - full: false, - mockClient: &MockGitHubClient{ - username: "testuser", - joinYear: 2020, - }, - wantErr: false, - }, - { - name: "full range", - startYear: 2020, - endYear: 2024, - targetUser: "testuser", - full: true, - mockClient: &MockGitHubClient{ - username: "testuser", - joinYear: 2020, - }, - 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) - } - }) - } -} diff --git a/stl/geometry/text.go b/stl/geometry/text.go deleted file mode 100644 index 65e83d0..0000000 --- a/stl/geometry/text.go +++ /dev/null @@ -1,248 +0,0 @@ -package geometry - -import ( - "fmt" - "image/png" - "os" - - "github.com/fogleman/gg" - "github.com/github/gh-skyline/errors" - "github.com/github/gh-skyline/types" -) - -// Common configuration for rendered elements -type renderConfig struct { - startX float64 - startY float64 - startZ float64 - voxelScale float64 - depth float64 -} - -// TextConfig holds parameters for text rendering -type textRenderConfig struct { - renderConfig - text string - contextWidth int - contextHeight int - fontSize float64 -} - -// ImageConfig holds parameters for image rendering -type imageRenderConfig struct { - renderConfig - imagePath string - height float64 -} - -const ( - imagePosition = 0.025 - usernameOffset = -0.01 - yearPosition = 0.77 - - defaultContextWidth = 800 - defaultContextHeight = 200 - textVoxelSize = 1.0 - textDepthOffset = 2.0 - frontEmbedDepth = 1.5 - - usernameContextWidth = 1000 - usernameContextHeight = 200 - usernameFontSize = 48.0 - usernameZOffset = 0.7 - - yearContextWidth = 800 - yearContextHeight = 200 - yearFontSize = 56.0 - yearZOffset = 0.4 - - defaultImageHeight = 9.0 - defaultImageScale = 0.8 - imageLeftMargin = 10.0 -) - -// Create3DText generates 3D text geometry for the username and year. -func Create3DText(username string, year string, innerWidth, baseHeight float64) ([]types.Triangle, error) { - if username == "" { - username = "anonymous" - } - - usernameConfig := textRenderConfig{ - renderConfig: renderConfig{ - startX: innerWidth * usernameOffset, - startY: -textDepthOffset / 2, - startZ: baseHeight * usernameZOffset, - voxelScale: textVoxelSize, - depth: frontEmbedDepth, - }, - text: username, - contextWidth: usernameContextWidth, - contextHeight: usernameContextHeight, - fontSize: usernameFontSize, - } - - yearConfig := textRenderConfig{ - renderConfig: renderConfig{ - startX: innerWidth * yearPosition, - startY: -textDepthOffset / 2, - startZ: baseHeight * yearZOffset, - voxelScale: textVoxelSize * 0.75, - depth: frontEmbedDepth, - }, - text: year, - contextWidth: yearContextWidth, - contextHeight: yearContextHeight, - fontSize: yearFontSize, - } - - usernameTriangles, err := renderText(usernameConfig) - if err != nil { - return nil, err - } - - yearTriangles, err := renderText(yearConfig) - if err != nil { - return nil, err - } - - return append(usernameTriangles, yearTriangles...), nil -} - -// renderText generates 3D geometry for the given text configuration. -func renderText(config textRenderConfig) ([]types.Triangle, error) { - dc := gg.NewContext(config.contextWidth, config.contextHeight) - - // Get temporary font file - fontPath, cleanup, err := writeTempFont(PrimaryFont) - if err != nil { - // Try fallback font - fontPath, cleanup, err = writeTempFont(FallbackFont) - if err != nil { - return nil, errors.New(errors.IOError, "failed to load any fonts", err) - } - } - - if err := dc.LoadFontFace(fontPath, config.fontSize); err != nil { - return nil, errors.New(errors.IOError, "failed to load font", err) - } - - dc.SetRGB(0, 0, 0) - dc.Clear() - dc.SetRGB(1, 1, 1) - dc.DrawStringAnchored(config.text, float64(config.contextWidth)/8, float64(config.contextHeight)/2, 0.0, 0.5) - - var triangles []types.Triangle - - for y := 0; y < config.contextHeight; y++ { - for x := 0; x < config.contextWidth; x++ { - if isPixelActive(dc, x, y) { - xPos := config.startX + float64(x)*config.voxelScale/8 - zPos := config.startZ - float64(y)*config.voxelScale/8 - - voxel, err := CreateCube( - xPos, - config.startY, - zPos, - config.voxelScale/2, - config.depth, - config.voxelScale/2, - ) - if err != nil { - return nil, errors.New(errors.STLError, "failed to create cube", err) - } - - triangles = append(triangles, voxel...) - } - } - } - - defer cleanup() - - return triangles, nil -} - -// GenerateImageGeometry creates 3D geometry from the embedded logo image. -func GenerateImageGeometry(innerWidth, baseHeight float64) ([]types.Triangle, error) { - // Get temporary image file - imgPath, cleanup, err := getEmbeddedImage() - if err != nil { - return nil, err - } - - config := imageRenderConfig{ - renderConfig: renderConfig{ - startX: innerWidth * imagePosition, - startY: -frontEmbedDepth / 2.0, - startZ: -0.85 * baseHeight, - voxelScale: defaultImageScale, - depth: frontEmbedDepth, - }, - imagePath: imgPath, - height: defaultImageHeight, - } - - defer cleanup() - - return renderImage(config) -} - -// renderImage generates 3D geometry for the given image configuration. -func renderImage(config imageRenderConfig) ([]types.Triangle, error) { - reader, err := os.Open(config.imagePath) - if err != nil { - return nil, errors.New(errors.IOError, "failed to open image", err) - } - defer func() { - if err := reader.Close(); err != nil { - closeErr := errors.New(errors.IOError, "failed to close reader", err) - // Log the error or handle it appropriately - fmt.Println(closeErr) - } - }() - - img, err := png.Decode(reader) - if err != nil { - return nil, errors.New(errors.IOError, "failed to decode PNG", err) - } - - bounds := img.Bounds() - width := bounds.Max.X - height := bounds.Max.Y - - scale := config.height / float64(height) - - var triangles []types.Triangle - - for y := height - 1; y >= 0; y-- { - for x := 0; x < width; x++ { - r, _, _, a := img.At(x, y).RGBA() - if a > 32768 && r > 32768 { - xPos := config.startX + float64(x)*config.voxelScale*scale - zPos := config.startZ + float64(height-1-y)*config.voxelScale*scale - - voxel, err := CreateCube( - xPos, - config.startY, - zPos, - config.voxelScale*scale, - config.depth, - config.voxelScale*scale, - ) - - if err != nil { - return nil, errors.New(errors.STLError, "failed to create cube", err) - } - - triangles = append(triangles, voxel...) - } - } - } - - return triangles, nil -} - -// isPixelActive checks if a pixel is active (white) in the given context. -func isPixelActive(dc *gg.Context, x, y int) bool { - r, _, _, _ := dc.Image().At(x, y).RGBA() - return r > 32768 -}