diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f49fea5..6c71182 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -29,13 +29,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/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..8166258 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -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/.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/README.md b/README.md index 7952ccc..6416604 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,20 @@ 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` ### Examples @@ -76,7 +84,34 @@ Enable debug logging: 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) ## Project Structure diff --git a/go.mod b/go.mod index 3a61c55..50dfda7 100644 --- a/go.mod +++ b/go.mod @@ -7,24 +7,29 @@ toolchain go1.23.3 require ( github.com/cli/go-gh/v2 v2.11.1 github.com/fogleman/gg v1.3.0 + github.com/spf13/cobra v1.8.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/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/muesli/termenv v0.15.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.5 // 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 + golang.org/x/image v0.23.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/term v0.27.0 // indirect + golang.org/x/text v0.21.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 4f9c6fb..35d856f 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/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.11.1 h1:amAyfqMWQTBdue8iTmDUegGZK7c8kk6WCxD9l/wLtGI= github.com/cli/go-gh/v2 v2.11.1/go.mod h1:MeRoKzXff3ygHu7zP+NVTT+imcHW6p3tpuxHAzRM2xE= github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00= github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= github.com/cli/shurcooL-graphql v0.0.4 h1:6MogPnQJLjKkaXPyGqPRXOI2qCsQdqNfUY1QSJu2GuY= github.com/cli/shurcooL-graphql v0.0.4/go.mod h1:3waN4u02FiZivIV+p1y4d0Jo1jc6BViMA73C+sZo2fk= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/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,10 +16,14 @@ 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/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.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -34,20 +41,25 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/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= +golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= +golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/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/logger/logger.go b/logger/logger.go index 2922083..3e5575e 100644 --- a/logger/logger.go +++ b/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/main.go b/main.go index 5af66a3..e815810 100644 --- a/main.go +++ b/main.go @@ -3,7 +3,6 @@ package main import ( - "flag" "fmt" "os" "strconv" @@ -11,21 +10,113 @@ import ( "time" "github.com/cli/go-gh/v2/pkg/api" + "github.com/cli/go-gh/v2/pkg/browser" "github.com/github/gh-skyline/ascii" "github.com/github/gh-skyline/errors" "github.com/github/gh-skyline/github" "github.com/github/gh-skyline/logger" "github.com/github/gh-skyline/stl" "github.com/github/gh-skyline/types" + "github.com/spf13/cobra" ) +// Browser interface matches browser.Browser functionality +type Browser interface { + Browse(url string) error +} + +// GitHubClientInterface defines the methods for interacting with GitHub API +type GitHubClientInterface interface { + GetAuthenticatedUser() (string, error) + GetUserJoinYear(username string) (int, error) + FetchContributions(username string, year int) (*types.ContributionsResponse, error) +} + +// Constants for GitHub launch year and default output file format 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" ) +// Command line variables and root command configuration +var ( + yearRange string + user string + full bool + debug bool + web bool + output string // new output path flag + + rootCmd = &cobra.Command{ + Use: "skyline", + Short: "Generate a 3D model of a user's GitHub contribution history", + Long: `GitHub Skyline creates 3D printable STL files from GitHub contribution data. +It can generate models for specific years or year ranges for the authenticated user or an optional specified user. + +While the STL file is being generated, an ASCII preview will be displayed in the terminal. + +ASCII Preview Legend: + ' ' Empty/Sky - No contributions + '.' Future dates - What contributions could you make? + '░' Low level - Light contribution activity + '▒' Medium level - Moderate contribution activity + '▓' High level - Heavy contribution activity + '╻┃╽' Top level - Last block with contributions in the week (Low, Medium, High) + +Layout: +Each column represents one week. Days within each week are reordered vertically +to create a "building" effect, with empty spaces (no contributions) at the top.`, + RunE: func(_ *cobra.Command, _ []string) error { + log := logger.GetLogger() + if debug { + log.SetLevel(logger.DEBUG) + if err := log.Debug("Debug logging enabled"); err != nil { + return err + } + } + + client, err := initializeGitHubClient() + if err != nil { + return errors.New(errors.NetworkError, "failed to initialize GitHub client", err) + } + + if web { + b := browser.New("", os.Stdout, os.Stderr) + if err := openGitHubProfile(user, client, b); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + return nil + } + + startYear, endYear, err := parseYearRange(yearRange) + if err != nil { + return fmt.Errorf("invalid year range: %v", err) + } + + return generateSkyline(startYear, endYear, user, full) + }, + } +) + +// init sets up command line flags for the skyline CLI tool +func init() { + rootCmd.Flags().StringVarP(&yearRange, "year", "y", fmt.Sprintf("%d", time.Now().Year()), "Year or year range (e.g., 2024 or 2014-2024)") + rootCmd.Flags().StringVarP(&user, "user", "u", "", "GitHub username (optional, defaults to authenticated user)") + rootCmd.Flags().BoolVarP(&full, "full", "f", false, "Generate contribution graph from join year to current year") + rootCmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging") + rootCmd.Flags().BoolVarP(&web, "web", "w", false, "Open GitHub profile (authenticated or specified user).") + rootCmd.Flags().StringVarP(&output, "output", "o", "", "Output file path (optional)") +} + +// main initializes and executes the root command for the GitHub Skyline CLI +func main() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + // formatYearRange returns a formatted string representation of the year range func formatYearRange(startYear, endYear int) string { if startYear == endYear { @@ -36,6 +127,13 @@ func formatYearRange(startYear, endYear int) string { // generateOutputFilename creates a consistent filename for the STL output func generateOutputFilename(user string, startYear, endYear int) string { + if output != "" { + // Ensure the filename ends with .stl + if !strings.HasSuffix(strings.ToLower(output), ".stl") { + return output + ".stl" + } + return output + } yearStr := formatYearRange(startYear, endYear) return fmt.Sprintf(outputFileFormat, user, yearStr) } @@ -143,40 +241,6 @@ func fetchContributionData(client *github.Client, username string, year int) ([] 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) - } -} - // Parse year range string (e.g., "2024" or "2014-2024") func parseYearRange(yearRange string) (startYear, endYear int, err error) { if strings.Contains(yearRange, "-") { @@ -202,6 +266,9 @@ func parseYearRange(yearRange string) (startYear, endYear int, err error) { 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 { @@ -212,3 +279,17 @@ func validateYearRange(startYear, endYear int) error { } return nil } + +// openGitHubProfile opens the GitHub profile page for the specified user or authenticated user +func openGitHubProfile(targetUser string, client GitHubClientInterface, b Browser) error { + if targetUser == "" { + username, err := client.GetAuthenticatedUser() + if err != nil { + return errors.New(errors.NetworkError, "failed to get authenticated user", err) + } + targetUser = username + } + + profileURL := fmt.Sprintf("https://github.com/%s", targetUser) + return b.Browse(profileURL) +} diff --git a/main_test.go b/main_test.go index 41816e1..ec9d22d 100644 --- a/main_test.go +++ b/main_test.go @@ -15,8 +15,9 @@ import ( // MockGitHubClient implements the github.APIClient interface type MockGitHubClient struct { - username string - joinYear int + username string + joinYear int + shouldError bool // Add error flag } // Get implements the APIClient interface @@ -79,14 +80,28 @@ func contributionResponse(username string) []byte { return []byte(response) } +// GetAuthenticatedUser returns the authenticated user's username or an error +// if the mock client is set to error or the username is not set. func (m *MockGitHubClient) GetAuthenticatedUser() (string, error) { + // Return error if shouldError is true + if m.shouldError { + return "", fmt.Errorf("mock client error") + } + // Validate username is not empty + if m.username == "" { + return "", fmt.Errorf("mock username not set") + } return m.username, nil } +// GetUserJoinYear implements the GitHubClientInterface. +// It returns the year the user joined GitHub. func (m *MockGitHubClient) GetUserJoinYear(_ string) (int, error) { return m.joinYear, nil } +// FetchContributions mocks fetching GitHub contributions for a user +// in a given year, returning minimal valid data. func (m *MockGitHubClient) FetchContributions(username string, year int) (*types.ContributionsResponse, error) { // Return minimal valid response resp := &types.ContributionsResponse{} @@ -108,6 +123,22 @@ func (m *MockGitHubClient) FetchContributions(username string, year int) (*types return resp, nil } +// MockBrowser implements the Browser interface +type MockBrowser struct { + LastURL string + ShouldError bool +} + +// Browse implements the Browser interface +// Changed from pointer receiver to value receiver +func (m *MockBrowser) Browse(url string) error { + m.LastURL = url + if m.ShouldError { + return fmt.Errorf("mock browser error") + } + return nil +} + func TestFormatYearRange(t *testing.T) { tests := []struct { name string @@ -337,3 +368,58 @@ func TestGenerateSkyline(t *testing.T) { }) } } + +// TestOpenGitHubProfile tests the openGitHubProfile function +func TestOpenGitHubProfile(t *testing.T) { + tests := []struct { + name string + targetUser string + mockClient *MockGitHubClient + wantURL string + wantErr bool + }{ + { + name: "specific user", + targetUser: "testuser", + mockClient: &MockGitHubClient{}, + wantURL: "https://github.com/testuser", + wantErr: false, + }, + { + name: "authenticated user", + targetUser: "", + mockClient: &MockGitHubClient{ + username: "authuser", + shouldError: false, + }, + wantURL: "https://github.com/authuser", + wantErr: false, + }, + { + name: "client error", + targetUser: "", + mockClient: &MockGitHubClient{ + username: "", + shouldError: true, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create MockBrowser and call openGitHubProfile + mockBrowser := &MockBrowser{ShouldError: tt.wantErr} + 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/stl/stl.go b/stl/stl.go index df70aa7..8d6c03f 100644 --- a/stl/stl.go +++ b/stl/stl.go @@ -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 }