├── .env ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.yaml │ ├── config.yaml │ └── feature_request.yaml └── workflows │ ├── auto-add-to-project.yml │ ├── compliance.yaml │ ├── release.yaml │ └── test.yaml ├── .gitignore ├── .golangci.yaml ├── .sauced.yaml ├── CHANGELOG.md ├── CODEOWNERS ├── Dockerfile ├── LICENSE ├── README.md ├── api ├── auth │ ├── auth.go │ ├── schema.go │ └── success.html ├── client.go ├── mock │ └── mock.go ├── services │ ├── contributors │ │ ├── contributors.go │ │ ├── contributors_test.go │ │ └── spec.go │ ├── histogram │ │ ├── histogram.go │ │ ├── histogram_test.go │ │ └── spec.go │ ├── repository │ │ ├── repository.go │ │ ├── repository_test.go │ │ └── spec.go │ ├── spec.go │ └── workspaces │ │ ├── spec.go │ │ ├── userlists │ │ ├── spec.go │ │ ├── userlists.go │ │ └── userlists_test.go │ │ ├── workspaces.go │ │ └── workspaces_test.go └── utils │ └── validators.go ├── cmd ├── auth │ └── auth.go ├── docs │ ├── docs.go │ └── docs_test.go ├── generate │ ├── codeowners │ │ ├── codeowners.go │ │ ├── output.go │ │ ├── output_test.go │ │ ├── spec.go │ │ └── traversal.go │ ├── config │ │ ├── config.go │ │ ├── output.go │ │ └── spec.go │ ├── generate.go │ └── insight │ │ └── insight.go ├── insights │ ├── contributors.go │ ├── insights.go │ ├── repositories.go │ ├── user-contributions.go │ └── utils.go ├── offboard │ ├── offboard.go │ └── output.go ├── root │ └── root.go └── version │ └── version.go ├── docs ├── pizza.md ├── pizza_completion.md ├── pizza_completion_bash.md ├── pizza_completion_fish.md ├── pizza_completion_powershell.md ├── pizza_completion_zsh.md ├── pizza_generate.md ├── pizza_generate_codeowners.md ├── pizza_generate_config.md ├── pizza_generate_insight.md ├── pizza_insights.md ├── pizza_insights_contributors.md ├── pizza_insights_repositories.md ├── pizza_insights_user-contributions.md ├── pizza_login.md ├── pizza_offboard.md └── pizza_version.md ├── go.mod ├── go.sum ├── install.sh ├── justfile ├── main.go ├── npm ├── .gitignore ├── README.md ├── bin │ └── runner.js ├── install.js ├── package-lock.json └── package.json ├── pkg ├── config │ ├── config.go │ ├── config_test.go │ ├── file.go │ └── spec.go ├── constants │ ├── api.go │ ├── flags.go │ ├── output.go │ └── templates.go ├── logging │ └── constants.go └── utils │ ├── arguments.go │ ├── github.go │ ├── output.go │ ├── posthog.go │ ├── root.go │ ├── telemetry.go │ └── version.go ├── scripts └── generate-docs.sh └── telemetry.go /.env: -------------------------------------------------------------------------------- 1 | # Env vars used in the justfile during buildtime 2 | 3 | POSTHOG_PUBLIC_API_KEY="phc_Y0xz6nK55MEwWjobJsI2P8rsiomZJ6eZLoXehmMy9tt" 4 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | @open-sauced/engineering 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug report 2 | description: Create a bug report to help us improve the OpenSauced CLI 🍕 3 | title: "Bug: " 4 | labels: [👀 needs triage, 🐛 bug, pizza-cli] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out this bug report! 10 | - type: textarea 11 | attributes: 12 | label: Describe the bug 13 | description: A clear and concise description of what the bug is. 14 | validations: 15 | required: true 16 | - type: textarea 17 | attributes: 18 | label: Steps to reproduce 19 | description: Describe how to reproduce the behavior. 20 | validations: 21 | required: true 22 | - type: markdown 23 | attributes: 24 | value: | 25 | Please include the following 26 | 27 | 1. System settings 28 | 2. Operating system version and special configurations 29 | 3. Shell environment (`echo $SHELL`) and version 30 | 4. `pizza version` output 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yaml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: ❓ Ask a question 4 | url: https://github.com/orgs/open-sauced/discussions 5 | about: Ask questions about OpenSauced 6 | - name: 🪿 Security 7 | url: mailto:security@opensauced.pizza 8 | about: Contact us about security concerns 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yaml: -------------------------------------------------------------------------------- 1 | name: 🚀 Feature request 2 | description: Suggest an idea for the Pizza CLI 💡 3 | title: "Feature: " 4 | labels: [👀 needs triage, 💡 feature, pizza-cli] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out this feature request! 10 | - type: textarea 11 | attributes: 12 | label: Suggested solution 13 | description: Describe the solution you'd like. 14 | -------------------------------------------------------------------------------- /.github/workflows/auto-add-to-project.yml: -------------------------------------------------------------------------------- 1 | name: "Adds issues to Team Dashboard" 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | 8 | jobs: 9 | add-to-project: 10 | name: Add issue to project 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Generate token 14 | id: generate_token 15 | uses: tibdex/github-app-token@v1 16 | with: 17 | app_id: ${{ secrets.OS_GITHUB_APP_ID }} 18 | private_key: ${{ secrets.OS_GITHUB_APP_PRIVATE_KEY }} 19 | 20 | - name: add issue to team dashboard 21 | uses: actions/add-to-project@v0.5.0 22 | with: 23 | project-url: https://github.com/orgs/open-sauced/projects/25 24 | github-token: ${{ steps.generate_token.outputs.token }} 25 | -------------------------------------------------------------------------------- /.github/workflows/compliance.yaml: -------------------------------------------------------------------------------- 1 | name: "Compliance" 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | permissions: 11 | pull-requests: write 12 | 13 | jobs: 14 | compliance: 15 | uses: open-sauced/hot/.github/workflows/compliance.yml@main 16 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: "Semantic release" 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - beta 8 | workflow_dispatch: 9 | 10 | concurrency: 11 | group: release-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | test: 16 | name: Test, lint, & build 17 | uses: ./.github/workflows/test.yaml 18 | 19 | release: 20 | outputs: 21 | release-tag: ${{ steps.semantic-release.outputs.release-tag }} 22 | name: Semantic release 23 | runs-on: ubuntu-latest 24 | timeout-minutes: 10 25 | steps: 26 | - name: Generate token 27 | id: generate_token 28 | uses: tibdex/github-app-token@v2 29 | with: 30 | app_id: ${{ secrets.OS_GITHUB_APP_ID }} 31 | private_key: ${{ secrets.OS_GITHUB_APP_PRIVATE_KEY }} 32 | 33 | - name: "☁️ checkout repository" 34 | uses: actions/checkout@v4 35 | with: 36 | fetch-depth: 0 37 | token: ${{ steps.generate_token.outputs.token }} 38 | 39 | - name: "🔧 setup node" 40 | uses: actions/setup-node@v4 41 | with: 42 | node-version: 18 43 | cache: "npm" 44 | cache-dependency-path: "./npm/package-lock.json" 45 | 46 | - name: "🔧 install npm@latest" 47 | run: npm i -g npm@latest 48 | 49 | - name: "🚀 release" 50 | id: semantic-release 51 | uses: open-sauced/release@v2 52 | env: 53 | # This ensures that publishing happens on every single trigger which then 54 | # forces the go binaries to be built in the next step and attached to the GitHub release 55 | FORCE_PUBLISH: "patch" 56 | 57 | GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} 58 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 59 | NPM_PACKAGE_ROOT: "npm" 60 | SKIP_DOCKER_PUBLISH: true 61 | 62 | docs: 63 | name: Update documentation 64 | needs: 65 | - release 66 | 67 | runs-on: ubuntu-latest 68 | 69 | steps: 70 | - name: "Generate token" 71 | id: generate_token 72 | uses: tibdex/github-app-token@v2 73 | with: 74 | app_id: ${{ secrets.OS_GITHUB_APP_ID }} 75 | private_key: ${{ secrets.OS_GITHUB_APP_PRIVATE_KEY }} 76 | 77 | - name: "☁️ checkout repository" 78 | uses: actions/checkout@v4 79 | with: 80 | fetch-depth: 0 81 | token: ${{ steps.generate_token.outputs.token }} 82 | 83 | - name: "🐹 Setup Go" 84 | uses: actions/setup-go@v5 85 | with: 86 | go-version: 1.22.x 87 | 88 | - name: "🤲 Setup Just" 89 | uses: extractions/setup-just@v2 90 | 91 | - name: "📗 Generate Documentation" 92 | run: ./scripts/generate-docs.sh 93 | env: 94 | GITHUB_REF: ${{ github.ref }} 95 | GH_TOKEN: ${{ steps.generate_token.outputs.token }} 96 | 97 | build: 98 | name: Build and publish artifacts 99 | needs: 100 | - release 101 | - docs 102 | if: needs.release.outputs.release-tag != '' 103 | runs-on: ubuntu-latest 104 | permissions: 105 | # release changes require contents write so that it can push Go binaries 106 | contents: write 107 | strategy: 108 | matrix: 109 | goos: [darwin, linux, windows] 110 | goarch: [amd64, arm64] 111 | 112 | steps: 113 | - name: "☁️ checkout repository" 114 | uses: actions/checkout@v4 115 | 116 | - name: "🐹 Setup Go" 117 | uses: actions/setup-go@v5 118 | with: 119 | go-version: 1.22.x 120 | 121 | - name: "🤲 Setup Just" 122 | uses: extractions/setup-just@v2 123 | 124 | - name: "🔧 Build all and upload artifacts to release" 125 | env: 126 | GH_TOKEN: ${{ github.token }} 127 | run: | 128 | export RELEASE_TAG_VERSION=${{ needs.release.outputs.release-tag }} 129 | just build-${{ matrix.goos }}-${{ matrix.goarch }} 130 | 131 | gh release upload ${{ needs.release.outputs.release-tag }} build/pizza-${{ matrix.goos }}-${{ matrix.goarch }} 132 | 133 | docker: 134 | name: Build and push container 135 | needs: 136 | - release 137 | if: needs.release.outputs.release-tag != '' 138 | runs-on: ubuntu-latest 139 | steps: 140 | - name: "☁️ checkout repository" 141 | uses: actions/checkout@v4 142 | 143 | - name: "🔧 setup buildx" 144 | uses: docker/setup-buildx-action@v3 145 | 146 | - name: "🐳 Login to ghcr" 147 | uses: docker/login-action@v3 148 | with: 149 | registry: ghcr.io 150 | username: ${{ github.actor }} 151 | password: ${{ secrets.GITHUB_TOKEN }} 152 | 153 | - name: "📦 docker build and push" 154 | uses: docker/build-push-action@v6 155 | with: 156 | tags: ghcr.io/${{ github.repository }}:latest,ghcr.io/${{ github.repository }}:${{ needs.release.outputs.release-tag }} 157 | push: true 158 | build-args: | 159 | VERSION=${{ needs.release.outputs.release-tag }} 160 | POSTHOG_PUBLIC_API_KEY=${{ vars.POSTHOG_WRITE_PUBLIC_KEY }} 161 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: "Lint, test, and build" 2 | 3 | on: 4 | workflow_call: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | types: 10 | - opened 11 | - edited 12 | - synchronize 13 | - reopened 14 | 15 | permissions: 16 | 17 | # So golangci-lint can read the contents of the lint yaml file 18 | contents: read 19 | 20 | jobs: 21 | lint: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4 25 | - uses: actions/setup-go@v5 26 | with: 27 | go-version: '1.22' 28 | - name: golangci-lint 29 | uses: golangci/golangci-lint-action@v6 30 | with: 31 | version: v1.60 32 | 33 | test: 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v4 37 | - uses: actions/setup-go@v5 38 | with: 39 | go-version: '1.22' 40 | - uses: extractions/setup-just@v2 41 | - name: Test 42 | run: just test 43 | 44 | build: 45 | runs-on: ubuntu-latest 46 | steps: 47 | - uses: actions/checkout@v4 48 | - uses: actions/setup-go@v5 49 | with: 50 | go-version: '1.22' 51 | - uses: extractions/setup-just@v2 52 | - name: Build Go binary 53 | run: just build 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore stuff from the builds that get generated. 2 | build/ 3 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable: 3 | - asasalint 4 | - asciicheck 5 | - bidichk 6 | - canonicalheader 7 | - errcheck 8 | - gci 9 | - goimports 10 | - gocritic 11 | - gosec 12 | - govet 13 | - ineffassign 14 | - misspell 15 | - revive 16 | - perfsprint 17 | - staticcheck 18 | - unconvert 19 | - unused 20 | - testifylint 21 | 22 | linters-settings: 23 | gci: 24 | sections: 25 | # include the default standard section, default section, and the "local" 26 | # section which can be configured with 27 | # 'goimports -local github.com/open-sauced/saucectl' 28 | - standard 29 | - default 30 | - localmodule 31 | 32 | run: 33 | timeout: 5m 34 | 35 | # attempts to automatically fix linting errors that are fixable by supported linters 36 | fix: true 37 | -------------------------------------------------------------------------------- /.sauced.yaml: -------------------------------------------------------------------------------- 1 | # Configuration for attributing commits with emails to GitHub user profiles 2 | # Used during codeowners generation. 3 | 4 | # List the emails associated with the given username. 5 | # The commits associated with these emails will be attributed to 6 | # the username in this yaml map. Any number of emails may be listed. 7 | attribution: 8 | brandonroberts: 9 | - robertsbt@gmail.com 10 | jpmcb: 11 | - john@opensauced.pizza 12 | nickytonline: 13 | - nick@nickyt.co 14 | - nick@opensauced.pizza 15 | zeucapua: 16 | - coding@zeu.dev 17 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # This file is generated automatically by OpenSauced pizza-cli. DO NOT EDIT. Stay saucy! 2 | # 3 | # Generated with command: 4 | # $ pizza generate codeowners pizza-cli/ --tty-disable true 5 | 6 | .env @jpmcb @zeucapua 7 | .github/ISSUE_TEMPLATE/bug_report.yaml @jpmcb 8 | .github/ISSUE_TEMPLATE/config.yaml @jpmcb 9 | .github/ISSUE_TEMPLATE/feature_request.yaml @jpmcb 10 | .github/workflows/auto-add-to-project.yml @jpmcb 11 | .github/workflows/pizza.yml @jpmcb @nickytonline @zeucapua 12 | .github/workflows/release.yaml @jpmcb @nickytonline @zeucapua 13 | .github/workflows/test.yaml @jpmcb @zeucapua 14 | .golangci.yaml @jpmcb @zeucapua 15 | .sauced.yaml @jpmcb @nickytonline @zeucapua 16 | CHANGELOG.md @jpmcb @zeucapua @nickytonline 17 | CODEOWNERS @jpmcb @nickytonline 18 | Dockerfile @jpmcb @nickytonline @zeucapua 19 | Makefile @jpmcb 20 | README.md @jpmcb 21 | api/auth/auth.go @jpmcb @zeucapua 22 | api/auth/success.html @nickytonline @jpmcb 23 | api/client.go @jpmcb @zeucapua 24 | api/mock/mock.go @jpmcb 25 | api/services/contributors/contributors.go @jpmcb @zeucapua 26 | api/services/contributors/contributors_test.go @jpmcb @zeucapua 27 | api/services/contributors/spec.go @jpmcb 28 | api/services/histogram/histogram.go @jpmcb @zeucapua 29 | api/services/histogram/histogram_test.go @jpmcb @zeucapua 30 | api/services/histogram/spec.go @jpmcb 31 | api/services/repository/repository.go @jpmcb @zeucapua 32 | api/services/repository/repository_test.go @jpmcb @zeucapua 33 | api/services/repository/spec.go @jpmcb 34 | api/services/spec.go @jpmcb 35 | api/services/workspaces/spec.go @jpmcb @zeucapua 36 | api/services/workspaces/userlists/spec.go @jpmcb @zeucapua 37 | api/services/workspaces/userlists/userlists.go @jpmcb @zeucapua 38 | api/services/workspaces/userlists/userlists_test.go @jpmcb @zeucapua 39 | api/services/workspaces/workspaces.go @jpmcb @zeucapua 40 | api/services/workspaces/workspaces_test.go @jpmcb @zeucapua 41 | api/utils/validators.go @jpmcb 42 | cmd/auth/auth.go @jpmcb @zeucapua 43 | cmd/auth/constants.go @jpmcb 44 | cmd/auth/schema.go @jpmcb 45 | cmd/auth/success.html @jpmcb 46 | cmd/auth/success.html @jpmcb 47 | cmd/bake/bake.go @jpmcb 48 | cmd/bake/bake_test.go @jpmcb 49 | cmd/docs/docs.go @nickytonline @jpmcb @zeucapua 50 | cmd/docs/docs_test.go @nickytonline @jpmcb @zeucapua 51 | cmd/generate/codeowners/codeowners.go @jpmcb @zeucapua 52 | cmd/generate/codeowners/output.go @jpmcb @zeucapua @brandonroberts 53 | cmd/generate/codeowners/output_test.go @jpmcb @brandonroberts @zeucapua 54 | cmd/generate/codeowners/spec.go @jpmcb 55 | cmd/generate/codeowners/traversal.go @jpmcb 56 | cmd/generate/config/config.go @zeucapua @jpmcb @zeucapua 57 | cmd/generate/config/output.go @jpmcb @zeucapua @zeucapua 58 | cmd/generate/config/spec.go @zeucapua @zeucapua @jpmcb 59 | cmd/generate/generate.go @jpmcb @zeucapua 60 | cmd/generate/insight/insight.go @jpmcb 61 | cmd/insights/contributors.go @jpmcb @zeucapua 62 | cmd/insights/insights.go @jpmcb @brandonroberts 63 | cmd/insights/repositories.go @jpmcb @zeucapua 64 | cmd/insights/user-contributions.go @jpmcb @zeucapua 65 | cmd/insights/utils.go @jpmcb 66 | cmd/offboard/offboard.go @jpmcb @zeucapua @zeucapua 67 | cmd/offboard/output.go @jpmcb @zeucapua @zeucapua 68 | cmd/repo-query/repo-query.go @jpmcb 69 | cmd/root/root.go @jpmcb @brandonroberts @zeucapua 70 | cmd/show/constants.go @jpmcb 71 | cmd/show/contributors.go @jpmcb 72 | cmd/show/dashboard.go @jpmcb 73 | cmd/show/show.go @jpmcb 74 | cmd/show/tui.go @jpmcb 75 | cmd/version/version.go @jpmcb @nickytonline 76 | docs/pizza.md @jpmcb @zeucapua @nickytonline 77 | docs/pizza_completion.md @jpmcb @zeucapua @nickytonline 78 | docs/pizza_completion_bash.md @jpmcb @nickytonline @zeucapua 79 | docs/pizza_completion_fish.md @jpmcb @nickytonline @zeucapua 80 | docs/pizza_completion_powershell.md @jpmcb @nickytonline @zeucapua 81 | docs/pizza_completion_zsh.md @jpmcb @zeucapua @nickytonline 82 | docs/pizza_generate.md @jpmcb @nickytonline @zeucapua 83 | docs/pizza_generate_codeowners.md @jpmcb @zeucapua 84 | docs/pizza_generate_config.md @jpmcb 85 | docs/pizza_generate_insight.md @jpmcb 86 | docs/pizza_insights.md @jpmcb @nickytonline @zeucapua 87 | docs/pizza_insights_contributors.md @jpmcb @zeucapua @nickytonline 88 | docs/pizza_insights_repositories.md @jpmcb @nickytonline @zeucapua 89 | docs/pizza_insights_user-contributions.md @jpmcb @nickytonline @zeucapua 90 | docs/pizza_login.md @jpmcb @nickytonline @zeucapua 91 | docs/pizza_offboard.md @jpmcb 92 | docs/pizza_version.md @jpmcb @nickytonline @zeucapua 93 | go.mod @jpmcb @nickytonline @zeucapua 94 | go.sum @jpmcb @zeucapua @nickytonline 95 | justfile @jpmcb @zeucapua @nickytonline 96 | main.go @jpmcb 97 | npm/.gitignore @jpmcb @zeucapua 98 | npm/README.md @jpmcb 99 | npm/package-lock.json @jpmcb @nickytonline @zeucapua 100 | npm/package.json @jpmcb @nickytonline @brandonroberts 101 | pkg/api/client.go @jpmcb 102 | pkg/api/validation.go @jpmcb 103 | pkg/config/config.go @jpmcb @nickytonline @zeucapua 104 | pkg/config/config_test.go @nickytonline @jpmcb @zeucapua 105 | pkg/config/file.go @jpmcb 106 | pkg/config/spec.go @jpmcb @brandonroberts @zeucapua 107 | pkg/constants/flags.go @jpmcb 108 | pkg/logging/constants.go @jpmcb 109 | pkg/utils/posthog.go @jpmcb @zeucapua @zeucapua 110 | pkg/utils/root.go @jpmcb @zeucapua 111 | pkg/utils/telemetry.go @jpmcb 112 | pkg/utils/version.go @nickytonline @jpmcb 113 | scripts/generate-docs.sh @nickytonline @jpmcb 114 | telemetry.go @jpmcb @zeucapua @zeucapua 115 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM golang:1.22.5 AS builder 2 | 3 | ARG TARGETPLATFORM 4 | ARG BUILDPLATFORM 5 | ARG VERSION 6 | ARG SHA 7 | ARG DATETIME 8 | ARG POSTHOG_PUBLIC_API_KEY 9 | 10 | # Get the dependencies downloaded 11 | WORKDIR /app 12 | ENV CGO_ENABLED=0 13 | COPY go.* ./ 14 | RUN go mod download 15 | COPY . ./ 16 | 17 | # Build Go CLI binary 18 | RUN go build -ldflags="-s -w \ 19 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.Version=${VERSION}' \ 20 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.Sha=${SHA}' \ 21 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.Datetime=${DATETIME}' \ 22 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.writeOnlyPublicPosthogKey=${POSTHOG_PUBLIC_API_KEY}'" \ 23 | -o pizza . 24 | 25 | # Runner layer 26 | FROM --platform=$BUILDPLATFORM golang:alpine 27 | COPY --from=builder /app/pizza /usr/bin/ 28 | ENTRYPOINT ["/usr/bin/pizza"] 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 OpenSauced 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /api/auth/schema.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | type session struct { 4 | AccessToken string `json:"access_token"` 5 | RefreshToken string `json:"refresh_token"` 6 | TokenType string `json:"token_type"` 7 | ExpiresIn int64 `json:"expires_in"` 8 | ExpiresAt int64 `json:"expires_at"` 9 | User sessionUser `json:"user"` 10 | } 11 | 12 | type sessionUser struct { 13 | ID string `json:"id"` 14 | Aud string `json:"aud,omitempty"` 15 | Role string `json:"role"` 16 | Email string `json:"email"` 17 | EmailConfirmedAt string `json:"email_confirmed_at"` 18 | Phone string `json:"phone"` 19 | PhoneConfirmedAt string `json:"phone_confirmed_at"` 20 | ConfirmationSentAt string `json:"confirmation_sent_at"` 21 | ConfirmedAt string `json:"confirmed_at"` 22 | RecoverySentAt string `json:"recovery_sent_at"` 23 | NewEmail string `json:"new_email"` 24 | EmailChangeSentAt string `json:"email_change_sent_at"` 25 | NewPhone string `json:"new_phone"` 26 | PhoneChangeSentAt string `json:"phone_change_sent_at"` 27 | ReauthenticationSentAt string `json:"reauthentication_sent_at"` 28 | LastSignInAt string `json:"last_sign_in_at"` 29 | AppMetadata map[string]interface{} `json:"app_metadata"` 30 | UserMetadata map[string]interface{} `json:"user_metadata"` 31 | Factors []sessionUseFactor `json:"factors"` 32 | Identities []interface{} `json:"identities"` 33 | BannedUntil string `json:"banned_until"` 34 | CreatedAt string `json:"created_at"` 35 | UpdatedAt string `json:"updated_at"` 36 | DeletedAt string `json:"deleted_at"` 37 | } 38 | 39 | type sessionUseFactor struct { 40 | ID string `json:"id"` 41 | Status string `json:"status"` 42 | FriendlyName string `json:"friendly_name"` 43 | FactorType string `json:"factor_type"` 44 | } 45 | -------------------------------------------------------------------------------- /api/auth/success.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | OpenSauced Pizza-CLI 6 | 41 | 42 | 43 | 44 | 45 | 46 | 48 | 49 |
Doodles 51 |
52 |
Doodles 54 |
55 |
56 |

Authentication Successful

57 |

58 | You'll be redirected to the OpenSauced shortly 59 |

60 |
61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /api/client.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/open-sauced/pizza-cli/v2/api/services/contributors" 8 | "github.com/open-sauced/pizza-cli/v2/api/services/histogram" 9 | "github.com/open-sauced/pizza-cli/v2/api/services/repository" 10 | "github.com/open-sauced/pizza-cli/v2/api/services/workspaces" 11 | ) 12 | 13 | // Client is the API client for OpenSauced API 14 | type Client struct { 15 | // API services 16 | RepositoryService *repository.Service 17 | ContributorService *contributors.Service 18 | HistogramService *histogram.Service 19 | WorkspacesService *workspaces.Service 20 | 21 | // The configured http client for making API requests 22 | httpClient *http.Client 23 | 24 | // The API endpoint to use when making requests 25 | // Example: https://api.opensauced.pizza 26 | endpoint string 27 | } 28 | 29 | // NewClient returns a new API Client based on provided inputs 30 | func NewClient(endpoint string) *Client { 31 | httpClient := &http.Client{ 32 | // TODO (jpmcb): in the future, we can allow users to configure the API 33 | // timeout via some global flag 34 | Timeout: time.Second * 10, 35 | } 36 | 37 | client := Client{ 38 | httpClient: httpClient, 39 | endpoint: endpoint, 40 | } 41 | 42 | client.ContributorService = contributors.NewContributorsService(client.httpClient, client.endpoint) 43 | client.RepositoryService = repository.NewRepositoryService(client.httpClient, client.endpoint) 44 | client.HistogramService = histogram.NewHistogramService(client.httpClient, client.endpoint) 45 | client.WorkspacesService = workspaces.NewWorkspacesService(client.httpClient, client.endpoint) 46 | 47 | return &client 48 | } 49 | -------------------------------------------------------------------------------- /api/mock/mock.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import "net/http" 4 | 5 | // RoundTripper is a custom, mock http.RoundTripper used for testing and mocking 6 | // purposes ONLY. 7 | type RoundTripper struct { 8 | RoundTripFunc func(req *http.Request) (*http.Response, error) 9 | } 10 | 11 | // NewMockRoundTripper returns a new RoundTripper which will execut the given 12 | // roundTripFunc provided by the caller 13 | func NewMockRoundTripper(roundTripFunc func(req *http.Request) (*http.Response, error)) *RoundTripper { 14 | return &RoundTripper{ 15 | RoundTripFunc: roundTripFunc, 16 | } 17 | } 18 | 19 | // RoundTrip fufills the http.Client interface and executes the provided RoundTripFunc 20 | // given by the caller in the NewMockRoundTripper 21 | func (m *RoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { 22 | return m.RoundTripFunc(req) 23 | } 24 | -------------------------------------------------------------------------------- /api/services/contributors/contributors.go: -------------------------------------------------------------------------------- 1 | package contributors 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "strconv" 9 | "strings" 10 | ) 11 | 12 | // Service is the Contributors service used for accessing the "v2/contributors" 13 | // endpoint and API services 14 | type Service struct { 15 | httpClient *http.Client 16 | endpoint string 17 | } 18 | 19 | // NewContributorsService returns a new contributors Service 20 | func NewContributorsService(httpClient *http.Client, endpoint string) *Service { 21 | return &Service{ 22 | httpClient: httpClient, 23 | endpoint: endpoint, 24 | } 25 | } 26 | 27 | // NewPullRequestContributors calls the "v2/contributors/insights/new" API endpoint 28 | func (s *Service) NewPullRequestContributors(repos []string, rangeVal int) (*ContribResponse, *http.Response, error) { 29 | baseURL := s.endpoint + "/v2/contributors/insights/new" 30 | 31 | // Create URL with query parameters 32 | u, err := url.Parse(baseURL) 33 | if err != nil { 34 | return nil, nil, fmt.Errorf("error parsing URL: %v", err) 35 | } 36 | 37 | q := u.Query() 38 | q.Set("range", strconv.Itoa(rangeVal)) 39 | q.Set("repos", strings.Join(repos, ",")) 40 | u.RawQuery = q.Encode() 41 | 42 | resp, err := s.httpClient.Get(u.String()) 43 | if err != nil { 44 | return nil, resp, fmt.Errorf("error making request: %v", err) 45 | } 46 | defer resp.Body.Close() 47 | 48 | if resp.StatusCode != http.StatusOK { 49 | return nil, resp, fmt.Errorf("API request failed with status code: %d", resp.StatusCode) 50 | } 51 | 52 | var newContributorsResponse ContribResponse 53 | if err := json.NewDecoder(resp.Body).Decode(&newContributorsResponse); err != nil { 54 | return nil, resp, fmt.Errorf("error decoding response: %v", err) 55 | } 56 | 57 | return &newContributorsResponse, resp, nil 58 | } 59 | 60 | // RecentPullRequestContributors calls the "v2/contributors/insights/recent" API endpoint 61 | func (s *Service) RecentPullRequestContributors(repos []string, rangeVal int) (*ContribResponse, *http.Response, error) { 62 | baseURL := s.endpoint + "/v2/contributors/insights/recent" 63 | 64 | // Create URL with query parameters 65 | u, err := url.Parse(baseURL) 66 | if err != nil { 67 | return nil, nil, fmt.Errorf("error parsing URL: %v", err) 68 | } 69 | 70 | q := u.Query() 71 | q.Set("range", strconv.Itoa(rangeVal)) 72 | q.Set("repos", strings.Join(repos, ",")) 73 | u.RawQuery = q.Encode() 74 | 75 | resp, err := s.httpClient.Get(u.String()) 76 | if err != nil { 77 | return nil, resp, fmt.Errorf("error making request: %v", err) 78 | } 79 | defer resp.Body.Close() 80 | 81 | if resp.StatusCode != http.StatusOK { 82 | return nil, resp, fmt.Errorf("API request failed with status code: %d", resp.StatusCode) 83 | } 84 | 85 | var recentContributorsResponse ContribResponse 86 | if err := json.NewDecoder(resp.Body).Decode(&recentContributorsResponse); err != nil { 87 | return nil, resp, fmt.Errorf("error decoding response: %v", err) 88 | } 89 | 90 | return &recentContributorsResponse, resp, nil 91 | } 92 | 93 | // AlumniPullRequestContributors calls the "v2/contributors/insights/alumni" API endpoint 94 | func (s *Service) AlumniPullRequestContributors(repos []string, rangeVal int) (*ContribResponse, *http.Response, error) { 95 | baseURL := s.endpoint + "/v2/contributors/insights/alumni" 96 | 97 | // Create URL with query parameters 98 | u, err := url.Parse(baseURL) 99 | if err != nil { 100 | return nil, nil, fmt.Errorf("error parsing URL: %v", err) 101 | } 102 | 103 | q := u.Query() 104 | q.Set("range", strconv.Itoa(rangeVal)) 105 | q.Set("repos", strings.Join(repos, ",")) 106 | u.RawQuery = q.Encode() 107 | 108 | resp, err := s.httpClient.Get(u.String()) 109 | if err != nil { 110 | return nil, resp, fmt.Errorf("error making request: %v", err) 111 | } 112 | defer resp.Body.Close() 113 | 114 | if resp.StatusCode != http.StatusOK { 115 | return nil, resp, fmt.Errorf("API request failed with status code: %d", resp.StatusCode) 116 | } 117 | 118 | var alumniContributorsResponse ContribResponse 119 | if err := json.NewDecoder(resp.Body).Decode(&alumniContributorsResponse); err != nil { 120 | return nil, resp, fmt.Errorf("error decoding response: %v", err) 121 | } 122 | 123 | return &alumniContributorsResponse, resp, nil 124 | } 125 | 126 | // RepeatPullRequestContributors calls the "v2/contributors/insights/repeat" API endpoint 127 | func (s *Service) RepeatPullRequestContributors(repos []string, rangeVal int) (*ContribResponse, *http.Response, error) { 128 | baseURL := s.endpoint + "/v2/contributors/insights/repeat" 129 | 130 | // Create URL with query parameters 131 | u, err := url.Parse(baseURL) 132 | if err != nil { 133 | return nil, nil, fmt.Errorf("error parsing URL: %v", err) 134 | } 135 | 136 | q := u.Query() 137 | q.Set("range", strconv.Itoa(rangeVal)) 138 | q.Set("repos", strings.Join(repos, ",")) 139 | u.RawQuery = q.Encode() 140 | 141 | resp, err := s.httpClient.Get(u.String()) 142 | if err != nil { 143 | return nil, resp, fmt.Errorf("error making request: %v", err) 144 | } 145 | defer resp.Body.Close() 146 | 147 | if resp.StatusCode != http.StatusOK { 148 | return nil, resp, fmt.Errorf("API request failed with status code: %d", resp.StatusCode) 149 | } 150 | 151 | var repeatContributorsResponse ContribResponse 152 | if err := json.NewDecoder(resp.Body).Decode(&repeatContributorsResponse); err != nil { 153 | return nil, resp, fmt.Errorf("error decoding response: %v", err) 154 | } 155 | 156 | return &repeatContributorsResponse, resp, nil 157 | } 158 | 159 | // SearchPullRequestContributors calls the "v2/contributors/search" 160 | func (s *Service) SearchPullRequestContributors(repos []string, rangeVal int) (*ContribResponse, *http.Response, error) { 161 | baseURL := s.endpoint + "/v2/contributors/search" 162 | 163 | // Create URL with query parameters 164 | u, err := url.Parse(baseURL) 165 | if err != nil { 166 | return nil, nil, fmt.Errorf("error parsing URL: %v", err) 167 | } 168 | 169 | q := u.Query() 170 | q.Set("range", strconv.Itoa(rangeVal)) 171 | q.Set("repos", strings.Join(repos, ",")) 172 | u.RawQuery = q.Encode() 173 | 174 | resp, err := s.httpClient.Get(u.String()) 175 | if err != nil { 176 | return nil, resp, fmt.Errorf("error making request: %v", err) 177 | } 178 | defer resp.Body.Close() 179 | 180 | if resp.StatusCode != http.StatusOK { 181 | return nil, resp, fmt.Errorf("API request failed with status code: %d", resp.StatusCode) 182 | } 183 | 184 | var searchContributorsResponse ContribResponse 185 | if err := json.NewDecoder(resp.Body).Decode(&searchContributorsResponse); err != nil { 186 | return nil, resp, fmt.Errorf("error decoding response: %v", err) 187 | } 188 | 189 | return &searchContributorsResponse, resp, nil 190 | } 191 | -------------------------------------------------------------------------------- /api/services/contributors/spec.go: -------------------------------------------------------------------------------- 1 | package contributors 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/open-sauced/pizza-cli/v2/api/services" 7 | ) 8 | 9 | type DbContributor struct { 10 | AuthorLogin string `json:"author_login"` 11 | UserID int `json:"user_id"` 12 | UpdatedAt time.Time `json:"updated_at"` 13 | } 14 | 15 | type ContribResponse struct { 16 | Data []DbContributor `json:"data"` 17 | Meta services.MetaData `json:"meta"` 18 | } 19 | -------------------------------------------------------------------------------- /api/services/histogram/histogram.go: -------------------------------------------------------------------------------- 1 | package histogram 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "strconv" 9 | ) 10 | 11 | // Service is used to access the API "v2/histogram" endpoints and services 12 | type Service struct { 13 | httpClient *http.Client 14 | endpoint string 15 | } 16 | 17 | // NewHistogramService returns a new histogram Service 18 | func NewHistogramService(httpClient *http.Client, endpoint string) *Service { 19 | return &Service{ 20 | httpClient: httpClient, 21 | endpoint: endpoint, 22 | } 23 | } 24 | 25 | // PrsHistogram calls the "v2/histogram/pull-requests" endpoints 26 | func (s *Service) PrsHistogram(repo string, rangeVal int) ([]PrHistogramData, *http.Response, error) { 27 | baseURL := s.endpoint + "/v2/histogram/pull-requests" 28 | 29 | // Create URL with query parameters 30 | u, err := url.Parse(baseURL) 31 | if err != nil { 32 | return nil, nil, fmt.Errorf("error parsing URL: %v", err) 33 | } 34 | 35 | q := u.Query() 36 | q.Set("range", strconv.Itoa(rangeVal)) 37 | q.Set("repo", repo) 38 | u.RawQuery = q.Encode() 39 | 40 | resp, err := s.httpClient.Get(u.String()) 41 | if err != nil { 42 | return nil, resp, fmt.Errorf("error making request: %v", err) 43 | } 44 | defer resp.Body.Close() 45 | 46 | if resp.StatusCode != http.StatusOK { 47 | return nil, resp, fmt.Errorf("API request failed with status code: %d", resp.StatusCode) 48 | } 49 | 50 | var prHistogramData []PrHistogramData 51 | if err := json.NewDecoder(resp.Body).Decode(&prHistogramData); err != nil { 52 | return nil, resp, fmt.Errorf("error decoding response: %v", err) 53 | } 54 | 55 | return prHistogramData, resp, nil 56 | } 57 | -------------------------------------------------------------------------------- /api/services/histogram/histogram_test.go: -------------------------------------------------------------------------------- 1 | package histogram 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/open-sauced/pizza-cli/v2/api/mock" 14 | ) 15 | 16 | func TestPrsHistogram(t *testing.T) { 17 | t.Parallel() 18 | m := mock.NewMockRoundTripper(func(req *http.Request) (*http.Response, error) { 19 | // Check if the URL is correct 20 | assert.Equal(t, "https://api.example.com/v2/histogram/pull-requests?range=30&repo=testowner%2Ftestrepo", req.URL.String()) 21 | 22 | mockResponse := []PrHistogramData{ 23 | { 24 | PrCount: 1, 25 | }, 26 | { 27 | PrCount: 2, 28 | }, 29 | } 30 | 31 | // Convert the mock response to JSON 32 | responseBody, _ := json.Marshal(mockResponse) 33 | 34 | // Return the mock response 35 | return &http.Response{ 36 | StatusCode: http.StatusOK, 37 | Body: io.NopCloser(bytes.NewBuffer(responseBody)), 38 | }, nil 39 | }) 40 | 41 | client := &http.Client{Transport: m} 42 | service := NewHistogramService(client, "https://api.example.com") 43 | 44 | prs, resp, err := service.PrsHistogram("testowner/testrepo", 30) 45 | 46 | require.NoError(t, err) 47 | assert.NotNil(t, prs) 48 | assert.Equal(t, http.StatusOK, resp.StatusCode) 49 | assert.Len(t, prs, 2) 50 | assert.Equal(t, 1, prs[0].PrCount) 51 | assert.Equal(t, 2, prs[1].PrCount) 52 | } 53 | -------------------------------------------------------------------------------- /api/services/histogram/spec.go: -------------------------------------------------------------------------------- 1 | package histogram 2 | 3 | import "time" 4 | 5 | type PrHistogramData struct { 6 | Bucket time.Time `json:"bucket"` 7 | PrCount int `json:"prs_count"` 8 | AcceptedPrs int `json:"accepted_prs"` 9 | OpenPrs int `json:"open_prs"` 10 | ClosedPrs int `json:"closed_prs"` 11 | DraftPrs int `json:"draft_prs"` 12 | ActivePrs int `json:"active_prs"` 13 | SpamPrs int `json:"spam_prs"` 14 | PRVelocity int `json:"pr_velocity"` 15 | CollaboratorAssociatedPrs int `json:"collaborator_associated_prs"` 16 | ContributorAssociatedPrs int `json:"contributor_associated_prs"` 17 | MemberAssociatedPrs int `json:"member_associated_prs"` 18 | NonAssociatedPrs int `json:"non_associated_prs"` 19 | OwnerAssociatedPrs int `json:"owner_associated_prs"` 20 | CommentsOnPrs int `json:"comments_on_prs"` 21 | ReviewCommentsOnPrs int `json:"review_comments_on_prs"` 22 | } 23 | -------------------------------------------------------------------------------- /api/services/repository/repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "strconv" 10 | ) 11 | 12 | // Service is used to access the "v2/repos" endpoints and services 13 | type Service struct { 14 | httpClient *http.Client 15 | endpoint string 16 | } 17 | 18 | // NewRepositoryService returns a new repository Service 19 | func NewRepositoryService(httpClient *http.Client, endpoint string) *Service { 20 | return &Service{ 21 | httpClient: httpClient, 22 | endpoint: endpoint, 23 | } 24 | } 25 | 26 | // FindOneByOwnerAndRepo calls the "v2/repos/:owner/:name" endpoint 27 | func (rs *Service) FindOneByOwnerAndRepo(owner string, repo string) (*DbRepository, *http.Response, error) { 28 | url := fmt.Sprintf("%s/v2/repos/%s/%s", rs.endpoint, owner, repo) 29 | 30 | resp, err := rs.httpClient.Get(url) 31 | if err != nil { 32 | return nil, resp, fmt.Errorf("error making request: %v", err) 33 | } 34 | defer resp.Body.Close() 35 | 36 | if resp.StatusCode != http.StatusOK { 37 | return nil, resp, fmt.Errorf("API request failed with status code: %d", resp.StatusCode) 38 | } 39 | 40 | var repository DbRepository 41 | if err := json.NewDecoder(resp.Body).Decode(&repository); err != nil { 42 | return nil, resp, fmt.Errorf("error decoding response: %v", err) 43 | } 44 | 45 | return &repository, resp, nil 46 | } 47 | 48 | // FindContributorsByOwnerAndRepo calls the "v2/repos/:owner/:name/contributors" endpoint 49 | func (rs *Service) FindContributorsByOwnerAndRepo(owner string, repo string, rangeVal int) (*ContributorsResponse, *http.Response, error) { 50 | baseURL := fmt.Sprintf("%s/v2/repos/%s/%s/contributors", rs.endpoint, owner, repo) 51 | 52 | // Create URL with query parameters 53 | u, err := url.Parse(baseURL) 54 | if err != nil { 55 | return nil, nil, fmt.Errorf("error parsing URL: %v", err) 56 | } 57 | 58 | q := u.Query() 59 | q.Set("range", strconv.Itoa(rangeVal)) 60 | u.RawQuery = q.Encode() 61 | 62 | resp, err := rs.httpClient.Get(u.String()) 63 | if err != nil { 64 | return nil, resp, err 65 | } 66 | defer resp.Body.Close() 67 | 68 | body, err := io.ReadAll(resp.Body) 69 | if err != nil { 70 | return nil, resp, err 71 | } 72 | 73 | var contributorsResp ContributorsResponse 74 | err = json.Unmarshal(body, &contributorsResp) 75 | if err != nil { 76 | return nil, resp, err 77 | } 78 | 79 | return &contributorsResp, resp, nil 80 | } 81 | -------------------------------------------------------------------------------- /api/services/repository/repository_test.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/open-sauced/pizza-cli/v2/api/mock" 14 | "github.com/open-sauced/pizza-cli/v2/api/services" 15 | ) 16 | 17 | func TestFindOneByOwnerAndRepo(t *testing.T) { 18 | t.Parallel() 19 | m := mock.NewMockRoundTripper(func(req *http.Request) (*http.Response, error) { 20 | // Check if the URL is correct 21 | assert.Equal(t, "https://api.example.com/v2/repos/testowner/testrepo", req.URL.String()) 22 | 23 | mockResponse := DbRepository{ 24 | ID: 1, 25 | FullName: "testowner/testrepo", 26 | } 27 | 28 | // Convert the mock response to JSON 29 | responseBody, _ := json.Marshal(mockResponse) 30 | 31 | // Return the mock response 32 | return &http.Response{ 33 | StatusCode: http.StatusOK, 34 | Body: io.NopCloser(bytes.NewBuffer(responseBody)), 35 | }, nil 36 | }) 37 | 38 | client := &http.Client{Transport: m} 39 | service := NewRepositoryService(client, "https://api.example.com") 40 | 41 | repo, resp, err := service.FindOneByOwnerAndRepo("testowner", "testrepo") 42 | 43 | require.NoError(t, err) 44 | assert.NotNil(t, repo) 45 | assert.Equal(t, http.StatusOK, resp.StatusCode) 46 | assert.Equal(t, 1, repo.ID) 47 | assert.Equal(t, "testowner/testrepo", repo.FullName) 48 | } 49 | 50 | func TestFindContributorsByOwnerAndRepo(t *testing.T) { 51 | t.Parallel() 52 | m := mock.NewMockRoundTripper(func(req *http.Request) (*http.Response, error) { 53 | assert.Equal(t, "https://api.example.com/v2/repos/testowner/testrepo/contributors?range=30", req.URL.String()) 54 | 55 | mockResponse := ContributorsResponse{ 56 | Data: []DbContributorInfo{ 57 | { 58 | ID: 1, 59 | Login: "contributor1", 60 | }, 61 | { 62 | ID: 2, 63 | Login: "contributor2", 64 | }, 65 | }, 66 | Meta: services.MetaData{ 67 | Page: 1, 68 | Limit: 30, 69 | ItemCount: 2, 70 | PageCount: 1, 71 | HasPreviousPage: false, 72 | HasNextPage: false, 73 | }, 74 | } 75 | 76 | // Convert the mock response to JSON 77 | responseBody, _ := json.Marshal(mockResponse) 78 | 79 | // Return the mock response 80 | return &http.Response{ 81 | StatusCode: http.StatusOK, 82 | Body: io.NopCloser(bytes.NewBuffer(responseBody)), 83 | }, nil 84 | }) 85 | 86 | client := &http.Client{Transport: m} 87 | service := NewRepositoryService(client, "https://api.example.com") 88 | 89 | contributors, resp, err := service.FindContributorsByOwnerAndRepo("testowner", "testrepo", 30) 90 | 91 | require.NoError(t, err) 92 | assert.NotNil(t, contributors) 93 | assert.Equal(t, http.StatusOK, resp.StatusCode) 94 | assert.Len(t, contributors.Data, 2) 95 | 96 | // Check the first contributor 97 | assert.Equal(t, 1, contributors.Data[0].ID) 98 | assert.Equal(t, "contributor1", contributors.Data[0].Login) 99 | 100 | // Check the second contributor 101 | assert.Equal(t, 2, contributors.Data[1].ID) 102 | assert.Equal(t, "contributor2", contributors.Data[1].Login) 103 | 104 | // Check the meta information 105 | assert.Equal(t, 1, contributors.Meta.Page) 106 | assert.Equal(t, 30, contributors.Meta.Limit) 107 | assert.Equal(t, 2, contributors.Meta.ItemCount) 108 | assert.Equal(t, 1, contributors.Meta.PageCount) 109 | assert.False(t, contributors.Meta.HasPreviousPage) 110 | assert.False(t, contributors.Meta.HasNextPage) 111 | } 112 | -------------------------------------------------------------------------------- /api/services/repository/spec.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import "time" 4 | 5 | type DbRepository struct { 6 | ID int `json:"id"` 7 | UserID int `json:"user_id"` 8 | Size int `json:"size"` 9 | Issues int `json:"issues"` 10 | Stars int `json:"stars"` 11 | Forks int `json:"forks"` 12 | Watchers int `json:"watchers"` 13 | Subscribers int `json:"subscribers"` 14 | Network int `json:"network"` 15 | IsFork bool `json:"is_fork"` 16 | IsPrivate bool `json:"is_private"` 17 | IsTemplate bool `json:"is_template"` 18 | IsArchived bool `json:"is_archived"` 19 | IsDisabled bool `json:"is_disabled"` 20 | HasIssues bool `json:"has_issues"` 21 | HasProjects bool `json:"has_projects"` 22 | HasDownloads bool `json:"has_downloads"` 23 | HasWiki bool `json:"has_wiki"` 24 | HasPages bool `json:"has_pages"` 25 | HasDiscussions bool `json:"has_discussions"` 26 | CreatedAt time.Time `json:"created_at"` 27 | UpdatedAt time.Time `json:"updated_at"` 28 | PushedAt time.Time `json:"pushed_at"` 29 | DefaultBranch string `json:"default_branch"` 30 | NodeID string `json:"node_id"` 31 | GitURL string `json:"git_url"` 32 | SSHURL string `json:"ssh_url"` 33 | CloneURL string `json:"clone_url"` 34 | SvnURL string `json:"svn_url"` 35 | MirrorURL string `json:"mirror_url"` 36 | Name string `json:"name"` 37 | FullName string `json:"full_name"` 38 | Description string `json:"description"` 39 | Language string `json:"language"` 40 | License string `json:"license"` 41 | URL string `json:"url"` 42 | Homepage string `json:"homepage"` 43 | Topics []string `json:"topics"` 44 | OSSFScorecardTotalScore float64 `json:"ossf_scorecard_total_score"` 45 | OSSFScorecardDependencyUpdateScore float64 `json:"ossf_scorecard_dependency_update_score"` 46 | OSSFScorecardFuzzingScore float64 `json:"ossf_scorecard_fuzzing_score"` 47 | OSSFScorecardMaintainedScore float64 `json:"ossf_scorecard_maintained_score"` 48 | OSSFScorecardUpdatedAt time.Time `json:"ossf_scorecard_updated_at"` 49 | OpenIssuesCount int `json:"open_issues_count"` 50 | ClosedIssuesCount int `json:"closed_issues_count"` 51 | IssuesVelocityCount float64 `json:"issues_velocity_count"` 52 | OpenPRsCount int `json:"open_prs_count"` 53 | ClosedPRsCount int `json:"closed_prs_count"` 54 | MergedPRsCount int `json:"merged_prs_count"` 55 | DraftPRsCount int `json:"draft_prs_count"` 56 | SpamPRsCount int `json:"spam_prs_count"` 57 | PRVelocityCount float64 `json:"pr_velocity_count"` 58 | ForkVelocity float64 `json:"fork_velocity"` 59 | PRActiveCount int `json:"pr_active_count"` 60 | ActivityRatio float64 `json:"activity_ratio"` 61 | ContributorConfidence float64 `json:"contributor_confidence"` 62 | Health float64 `json:"health"` 63 | LastPushedAt time.Time `json:"last_pushed_at"` 64 | LastMainPushedAt time.Time `json:"last_main_pushed_at"` 65 | } 66 | 67 | // DbContributorInfo represents the structure of a single contributor 68 | type DbContributorInfo struct { 69 | ID int `json:"id"` 70 | Login string `json:"login"` 71 | AvatarURL string `json:"avatar_url"` 72 | Company string `json:"company"` 73 | Location string `json:"location"` 74 | OSCR float64 `json:"oscr"` 75 | Repos []string `json:"repos"` 76 | Tags []string `json:"tags"` 77 | Commits int `json:"commits"` 78 | PRsCreated int `json:"prs_created"` 79 | PRsReviewed int `json:"prs_reviewed"` 80 | IssuesCreated int `json:"issues_created"` 81 | CommitComments int `json:"commit_comments"` 82 | IssueComments int `json:"issue_comments"` 83 | PRReviewComments int `json:"pr_review_comments"` 84 | Comments int `json:"comments"` 85 | TotalContributions int `json:"total_contributions"` 86 | LastContributed time.Time `json:"last_contributed"` 87 | DevstatsUpdatedAt time.Time `json:"devstats_updated_at"` 88 | UpdatedAt time.Time `json:"updated_at"` 89 | } 90 | 91 | // ContributorsResponse represents the structure of the contributors endpoint response 92 | type ContributorsResponse struct { 93 | Data []DbContributorInfo `json:"data"` 94 | Meta struct { 95 | Page int `json:"page"` 96 | Limit int `json:"limit"` 97 | ItemCount int `json:"itemCount"` 98 | PageCount int `json:"pageCount"` 99 | HasPreviousPage bool `json:"hasPreviousPage"` 100 | HasNextPage bool `json:"hasNextPage"` 101 | } `json:"meta"` 102 | } 103 | -------------------------------------------------------------------------------- /api/services/spec.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | type MetaData struct { 4 | Page int `json:"page"` 5 | Limit int `json:"limit"` 6 | ItemCount int `json:"itemCount"` 7 | PageCount int `json:"pageCount"` 8 | HasPreviousPage bool `json:"hasPreviousPage"` 9 | HasNextPage bool `json:"hasNextPage"` 10 | } 11 | -------------------------------------------------------------------------------- /api/services/workspaces/spec.go: -------------------------------------------------------------------------------- 1 | package workspaces 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/open-sauced/pizza-cli/v2/api/services" 7 | ) 8 | 9 | type DbWorkspace struct { 10 | ID string `json:"id"` 11 | CreatedAt time.Time `json:"created_at"` 12 | UpdatedAt time.Time `json:"updated_at"` 13 | DeletedAt *time.Time `json:"deleted_at"` 14 | Name string `json:"name"` 15 | Description string `json:"description"` 16 | IsPublic bool `json:"is_public"` 17 | PayeeUserID *int `json:"payee_user_id"` 18 | Members []DbWorkspaceMember `json:"members"` 19 | } 20 | 21 | type DbWorkspaceMember struct { 22 | ID string `json:"id"` 23 | UserID int `json:"user_id"` 24 | WorkspaceID string `json:"workspace_id"` 25 | CreatedAt time.Time `json:"created_at"` 26 | UpdatedAt time.Time `json:"updated_at"` 27 | DeletedAt *time.Time `json:"deleted_at"` 28 | Role string `json:"role"` 29 | } 30 | 31 | type DbWorkspacesResponse struct { 32 | Data []DbWorkspace `json:"data"` 33 | Meta services.MetaData `json:"meta"` 34 | } 35 | 36 | type CreateWorkspaceRequestRepoInfo struct { 37 | FullName string `json:"full_name"` 38 | } 39 | 40 | type CreateWorkspaceRequest struct { 41 | Name string `json:"name"` 42 | Description string `json:"description"` 43 | Members []string `json:"members"` 44 | Repos []CreateWorkspaceRequestRepoInfo `json:"repos"` 45 | Contributors []string `json:"contributors"` 46 | } 47 | -------------------------------------------------------------------------------- /api/services/workspaces/userlists/spec.go: -------------------------------------------------------------------------------- 1 | package userlists 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/open-sauced/pizza-cli/v2/api/services" 7 | ) 8 | 9 | type DbUserListContributor struct { 10 | ID string `json:"id"` 11 | UserID int `json:"user_id"` 12 | ListID string `json:"list_id"` 13 | Username string `json:"username"` 14 | CreatedAt time.Time `json:"created_at"` 15 | } 16 | 17 | type DbUserList struct { 18 | ID string `json:"id"` 19 | UserID int `json:"user_id"` 20 | Name string `json:"name"` 21 | IsPublic bool `json:"is_public"` 22 | IsFeatured bool `json:"is_featured"` 23 | CreatedAt time.Time `json:"created_at"` 24 | UpdatedAt time.Time `json:"updated_at"` 25 | DeletedAt *time.Time `json:"deleted_at"` 26 | Contributors []DbUserListContributor `json:"contributors"` 27 | } 28 | 29 | type GetUserListsResponse struct { 30 | Data []DbUserList `json:"data"` 31 | Meta services.MetaData `json:"meta"` 32 | } 33 | 34 | type CreatePatchUserListRequest struct { 35 | Name string `json:"name"` 36 | IsPublic bool `json:"is_public"` 37 | Contributors []CreateUserListRequestContributor `json:"contributors"` 38 | } 39 | 40 | type CreateUserListRequestContributor struct { 41 | Login string `json:"login"` 42 | } 43 | 44 | type CreateUserListResponse struct { 45 | ID string `json:"id"` 46 | UserListID string `json:"user_list_id"` 47 | WorkspaceID string `json:"workspace_id"` 48 | } 49 | -------------------------------------------------------------------------------- /api/services/workspaces/userlists/userlists.go: -------------------------------------------------------------------------------- 1 | package userlists 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | "strconv" 10 | ) 11 | 12 | // Service is used to access the "v2/workspaces/:workspaceId/userLists" 13 | // endpoints and services 14 | type Service struct { 15 | httpClient *http.Client 16 | endpoint string 17 | } 18 | 19 | // NewService returns a new UserListsService 20 | func NewService(httpClient *http.Client, endpoint string) *Service { 21 | return &Service{ 22 | httpClient: httpClient, 23 | endpoint: endpoint, 24 | } 25 | } 26 | 27 | // GetUserLists calls the "GET v2/workspaces/:workspaceId/userLists" endpoint 28 | // for the authenticated user 29 | func (s *Service) GetUserLists(token string, workspaceID string, page, limit int) (*GetUserListsResponse, *http.Response, error) { 30 | baseURL := s.endpoint + "/v2/workspaces/" + workspaceID + "/userLists" 31 | 32 | // Create URL with query parameters 33 | u, err := url.Parse(baseURL) 34 | if err != nil { 35 | return nil, nil, fmt.Errorf("error parsing URL: %v", err) 36 | } 37 | 38 | q := u.Query() 39 | q.Set("page", strconv.Itoa(page)) 40 | q.Set("limit", strconv.Itoa(limit)) 41 | u.RawQuery = q.Encode() 42 | 43 | req, err := http.NewRequest("GET", u.String(), nil) 44 | if err != nil { 45 | return nil, nil, fmt.Errorf("error creating request: %w", err) 46 | } 47 | 48 | req.Header.Set("Authorization", "Bearer "+token) 49 | 50 | resp, err := s.httpClient.Do(req) 51 | if err != nil { 52 | return nil, nil, fmt.Errorf("error making request: %w", err) 53 | } 54 | defer resp.Body.Close() 55 | 56 | if resp.StatusCode != http.StatusOK { 57 | return nil, resp, fmt.Errorf("API request failed with status code: %d", resp.StatusCode) 58 | } 59 | 60 | var userListsResp GetUserListsResponse 61 | if err := json.NewDecoder(resp.Body).Decode(&userListsResp); err != nil { 62 | return nil, resp, fmt.Errorf("error decoding response: %w", err) 63 | } 64 | 65 | return &userListsResp, resp, nil 66 | } 67 | 68 | // GetUserList calls the "GET v2/workspaces/:workspaceId/userLists" endpoint 69 | // for the authenticated user 70 | func (s *Service) GetUserList(token string, workspaceID string, userlistID string) (*DbUserList, *http.Response, error) { 71 | url := s.endpoint + "/v2/workspaces/" + workspaceID + "/userLists/" + userlistID 72 | 73 | req, err := http.NewRequest("GET", url, nil) 74 | if err != nil { 75 | return nil, nil, fmt.Errorf("error creating request: %w", err) 76 | } 77 | 78 | req.Header.Set("Authorization", "Bearer "+token) 79 | 80 | resp, err := s.httpClient.Do(req) 81 | if err != nil { 82 | return nil, nil, fmt.Errorf("error making request: %w", err) 83 | } 84 | defer resp.Body.Close() 85 | 86 | if resp.StatusCode != http.StatusOK { 87 | return nil, resp, fmt.Errorf("API request failed with status code: %d", resp.StatusCode) 88 | } 89 | 90 | var userList DbUserList 91 | if err := json.NewDecoder(resp.Body).Decode(&userList); err != nil { 92 | return nil, resp, fmt.Errorf("error decoding response: %w", err) 93 | } 94 | 95 | return &userList, resp, nil 96 | } 97 | 98 | // CreateUserListForUser calls the "POST v2/workspaces/:workspaceId/userLists" endpoint 99 | // for the authenticated user 100 | func (s *Service) CreateUserListForUser(token string, workspaceID string, name string, logins []string) (*CreateUserListResponse, *http.Response, error) { 101 | url := s.endpoint + "/v2/workspaces/" + workspaceID + "/userLists" 102 | 103 | loginReqs := []CreateUserListRequestContributor{} 104 | for _, login := range logins { 105 | loginReqs = append(loginReqs, CreateUserListRequestContributor{Login: login}) 106 | } 107 | 108 | req := CreatePatchUserListRequest{ 109 | Name: name, 110 | IsPublic: false, 111 | Contributors: loginReqs, 112 | } 113 | 114 | payload, err := json.Marshal(req) 115 | if err != nil { 116 | return nil, nil, fmt.Errorf("error marshaling request: %w", err) 117 | } 118 | 119 | httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(payload)) 120 | if err != nil { 121 | return nil, nil, fmt.Errorf("error creating request: %w", err) 122 | } 123 | 124 | httpReq.Header.Set("Authorization", "Bearer "+token) 125 | httpReq.Header.Set("Content-Type", "application/json") 126 | httpReq.Header.Set("Accept", "application/json") 127 | 128 | resp, err := s.httpClient.Do(httpReq) 129 | if err != nil { 130 | return nil, resp, fmt.Errorf("error making request: %w", err) 131 | } 132 | defer resp.Body.Close() 133 | 134 | if resp.StatusCode != http.StatusCreated { 135 | return nil, resp, fmt.Errorf("API request failed with status code: %d", resp.StatusCode) 136 | } 137 | 138 | var createdUserList CreateUserListResponse 139 | if err := json.NewDecoder(resp.Body).Decode(&createdUserList); err != nil { 140 | return nil, resp, fmt.Errorf("error decoding response: %w", err) 141 | } 142 | 143 | return &createdUserList, resp, nil 144 | } 145 | 146 | // CreateUserListForUser calls the "PATCH v2/lists/:listId" endpoint 147 | // for the authenticated user 148 | func (s *Service) PatchUserListForUser(token string, workspaceID string, userlistID string, name string, logins []string) (*DbUserList, *http.Response, error) { 149 | url := s.endpoint + "/v2/workspaces/" + workspaceID + "/userLists/" + userlistID 150 | 151 | loginReqs := []CreateUserListRequestContributor{} 152 | for _, login := range logins { 153 | loginReqs = append(loginReqs, CreateUserListRequestContributor{Login: login}) 154 | } 155 | 156 | req := CreatePatchUserListRequest{ 157 | Name: name, 158 | IsPublic: false, 159 | Contributors: loginReqs, 160 | } 161 | 162 | payload, err := json.Marshal(req) 163 | if err != nil { 164 | return nil, nil, fmt.Errorf("error marshaling request: %w", err) 165 | } 166 | 167 | httpReq, err := http.NewRequest("PATCH", url, bytes.NewBuffer(payload)) 168 | if err != nil { 169 | return nil, nil, fmt.Errorf("error creating request: %w", err) 170 | } 171 | 172 | httpReq.Header.Set("Authorization", "Bearer "+token) 173 | httpReq.Header.Set("Content-Type", "application/json") 174 | httpReq.Header.Set("Accept", "application/json") 175 | 176 | resp, err := s.httpClient.Do(httpReq) 177 | if err != nil { 178 | return nil, resp, fmt.Errorf("error making request: %w", err) 179 | } 180 | defer resp.Body.Close() 181 | 182 | if resp.StatusCode != http.StatusOK { 183 | return nil, resp, fmt.Errorf("API request failed with status code: %d", resp.StatusCode) 184 | } 185 | 186 | var createdUserList DbUserList 187 | if err := json.NewDecoder(resp.Body).Decode(&createdUserList); err != nil { 188 | return nil, resp, fmt.Errorf("error decoding response: %w", err) 189 | } 190 | 191 | return &createdUserList, resp, nil 192 | } 193 | -------------------------------------------------------------------------------- /api/services/workspaces/userlists/userlists_test.go: -------------------------------------------------------------------------------- 1 | package userlists 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/open-sauced/pizza-cli/v2/api/mock" 14 | "github.com/open-sauced/pizza-cli/v2/api/services" 15 | ) 16 | 17 | func TestGetUserLists(t *testing.T) { 18 | t.Parallel() 19 | m := mock.NewMockRoundTripper(func(req *http.Request) (*http.Response, error) { 20 | assert.Equal(t, "https://api.example.com/v2/workspaces/abc123/userLists?limit=30&page=1", req.URL.String()) 21 | assert.Equal(t, "GET", req.Method) 22 | 23 | mockResponse := GetUserListsResponse{ 24 | Data: []DbUserList{ 25 | { 26 | ID: "abc", 27 | Name: "userlist1", 28 | }, 29 | { 30 | ID: "xyz", 31 | Name: "userlist2", 32 | }, 33 | }, 34 | Meta: services.MetaData{ 35 | Page: 1, 36 | Limit: 30, 37 | ItemCount: 2, 38 | PageCount: 1, 39 | HasPreviousPage: false, 40 | HasNextPage: false, 41 | }, 42 | } 43 | 44 | // Convert the mock response to JSON 45 | responseBody, _ := json.Marshal(mockResponse) 46 | 47 | // Return the mock response 48 | return &http.Response{ 49 | StatusCode: http.StatusOK, 50 | Body: io.NopCloser(bytes.NewBuffer(responseBody)), 51 | }, nil 52 | }) 53 | 54 | client := &http.Client{Transport: m} 55 | service := NewService(client, "https://api.example.com") 56 | 57 | userlists, resp, err := service.GetUserLists("token", "abc123", 1, 30) 58 | 59 | require.NoError(t, err) 60 | assert.NotNil(t, userlists) 61 | assert.Equal(t, http.StatusOK, resp.StatusCode) 62 | assert.Len(t, userlists.Data, 2) 63 | 64 | // First workspace 65 | assert.Equal(t, "abc", userlists.Data[0].ID) 66 | assert.Equal(t, "userlist1", userlists.Data[0].Name) 67 | 68 | // Second workspace 69 | assert.Equal(t, "xyz", userlists.Data[1].ID) 70 | assert.Equal(t, "userlist2", userlists.Data[1].Name) 71 | 72 | // Check the meta information 73 | assert.Equal(t, 1, userlists.Meta.Page) 74 | assert.Equal(t, 30, userlists.Meta.Limit) 75 | assert.Equal(t, 2, userlists.Meta.ItemCount) 76 | assert.Equal(t, 1, userlists.Meta.PageCount) 77 | assert.False(t, userlists.Meta.HasPreviousPage) 78 | assert.False(t, userlists.Meta.HasNextPage) 79 | } 80 | 81 | func TestGetUserListForUser(t *testing.T) { 82 | t.Parallel() 83 | m := mock.NewMockRoundTripper(func(req *http.Request) (*http.Response, error) { 84 | assert.Equal(t, "https://api.example.com/v2/workspaces/abc123/userLists/xyz", req.URL.String()) 85 | assert.Equal(t, "GET", req.Method) 86 | 87 | mockResponse := DbUserList{ 88 | ID: "abc", 89 | Name: "userlist1", 90 | } 91 | 92 | // Convert the mock response to JSON 93 | responseBody, _ := json.Marshal(mockResponse) 94 | 95 | // Return the mock response 96 | return &http.Response{ 97 | StatusCode: http.StatusOK, 98 | Body: io.NopCloser(bytes.NewBuffer(responseBody)), 99 | }, nil 100 | }) 101 | 102 | client := &http.Client{Transport: m} 103 | service := NewService(client, "https://api.example.com") 104 | 105 | userlists, resp, err := service.GetUserList("token", "abc123", "xyz") 106 | 107 | require.NoError(t, err) 108 | assert.NotNil(t, userlists) 109 | assert.Equal(t, http.StatusOK, resp.StatusCode) 110 | assert.Equal(t, "abc", userlists.ID) 111 | assert.Equal(t, "userlist1", userlists.Name) 112 | } 113 | 114 | func TestCreateUserListForUser(t *testing.T) { 115 | t.Parallel() 116 | m := mock.NewMockRoundTripper(func(req *http.Request) (*http.Response, error) { 117 | assert.Equal(t, "https://api.example.com/v2/workspaces/abc123/userLists", req.URL.String()) 118 | assert.Equal(t, "POST", req.Method) 119 | 120 | mockResponse := CreateUserListResponse{ 121 | ID: "abc", 122 | UserListID: "xyz", 123 | } 124 | 125 | // Convert the mock response to JSON 126 | responseBody, _ := json.Marshal(mockResponse) 127 | 128 | // Return the mock response 129 | return &http.Response{ 130 | StatusCode: http.StatusCreated, 131 | Body: io.NopCloser(bytes.NewBuffer(responseBody)), 132 | }, nil 133 | }) 134 | 135 | client := &http.Client{Transport: m} 136 | service := NewService(client, "https://api.example.com") 137 | 138 | userlists, resp, err := service.CreateUserListForUser("token", "abc123", "userlist1", []string{}) 139 | 140 | require.NoError(t, err) 141 | assert.NotNil(t, userlists) 142 | assert.Equal(t, http.StatusCreated, resp.StatusCode) 143 | assert.Equal(t, "abc", userlists.ID) 144 | assert.Equal(t, "xyz", userlists.UserListID) 145 | } 146 | 147 | func TestPatchUserListForUser(t *testing.T) { 148 | t.Parallel() 149 | m := mock.NewMockRoundTripper(func(req *http.Request) (*http.Response, error) { 150 | assert.Equal(t, "https://api.example.com/v2/workspaces/abc123/userLists/abc", req.URL.String()) 151 | assert.Equal(t, "PATCH", req.Method) 152 | 153 | mockResponse := DbUserList{ 154 | ID: "abc", 155 | Name: "userlist1", 156 | } 157 | 158 | // Convert the mock response to JSON 159 | responseBody, _ := json.Marshal(mockResponse) 160 | 161 | // Return the mock response 162 | return &http.Response{ 163 | StatusCode: http.StatusOK, 164 | Body: io.NopCloser(bytes.NewBuffer(responseBody)), 165 | }, nil 166 | }) 167 | 168 | client := &http.Client{Transport: m} 169 | service := NewService(client, "https://api.example.com") 170 | 171 | userlists, resp, err := service.PatchUserListForUser("token", "abc123", "abc", "userlist1", []string{}) 172 | 173 | require.NoError(t, err) 174 | assert.NotNil(t, userlists) 175 | assert.Equal(t, http.StatusOK, resp.StatusCode) 176 | assert.Equal(t, "abc", userlists.ID) 177 | assert.Equal(t, "userlist1", userlists.Name) 178 | } 179 | -------------------------------------------------------------------------------- /api/services/workspaces/workspaces.go: -------------------------------------------------------------------------------- 1 | package workspaces 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | "strconv" 10 | 11 | "github.com/open-sauced/pizza-cli/v2/api/services/workspaces/userlists" 12 | ) 13 | 14 | // Service is used to access the "v2/workspaces" endpoints and services. 15 | // It has a child service UserListService used for accessing workspace contributor insights 16 | type Service struct { 17 | UserListService *userlists.Service 18 | 19 | httpClient *http.Client 20 | endpoint string 21 | } 22 | 23 | // NewWorkspacesService returns a new workspace Service 24 | func NewWorkspacesService(httpClient *http.Client, endpoint string) *Service { 25 | userListService := userlists.NewService(httpClient, endpoint) 26 | 27 | return &Service{ 28 | UserListService: userListService, 29 | httpClient: httpClient, 30 | endpoint: endpoint, 31 | } 32 | } 33 | 34 | // GetWorkspaces calls the "GET v2/workspaces" endpoint for the authenticated user 35 | func (s *Service) GetWorkspaces(token string, page, limit int) (*DbWorkspacesResponse, *http.Response, error) { 36 | baseURL := s.endpoint + "/v2/workspaces" 37 | 38 | // Create URL with query parameters 39 | u, err := url.Parse(baseURL) 40 | if err != nil { 41 | return nil, nil, fmt.Errorf("error parsing URL: %v", err) 42 | } 43 | 44 | q := u.Query() 45 | q.Set("page", strconv.Itoa(page)) 46 | q.Set("limit", strconv.Itoa(limit)) 47 | u.RawQuery = q.Encode() 48 | 49 | req, err := http.NewRequest("GET", u.String(), nil) 50 | if err != nil { 51 | return nil, nil, fmt.Errorf("error creating request: %w", err) 52 | } 53 | 54 | req.Header.Set("Authorization", "Bearer "+token) 55 | 56 | resp, err := s.httpClient.Do(req) 57 | if err != nil { 58 | return nil, nil, fmt.Errorf("error making request: %w", err) 59 | } 60 | defer resp.Body.Close() 61 | 62 | if resp.StatusCode != http.StatusOK { 63 | return nil, resp, fmt.Errorf("API request failed with status code: %d", resp.StatusCode) 64 | } 65 | 66 | var workspacesResp DbWorkspacesResponse 67 | if err := json.NewDecoder(resp.Body).Decode(&workspacesResp); err != nil { 68 | return nil, resp, fmt.Errorf("error decoding response: %w", err) 69 | } 70 | 71 | return &workspacesResp, resp, nil 72 | } 73 | 74 | // CreateWorkspaceForUser calls the "POST v2/workspaces" endpoint for the authenticated user 75 | func (s *Service) CreateWorkspaceForUser(token string, name string, description string, repos []string) (*DbWorkspace, *http.Response, error) { 76 | url := s.endpoint + "/v2/workspaces" 77 | 78 | repoReqs := []CreateWorkspaceRequestRepoInfo{} 79 | for _, repo := range repos { 80 | repoReqs = append(repoReqs, CreateWorkspaceRequestRepoInfo{FullName: repo}) 81 | } 82 | 83 | req := CreateWorkspaceRequest{ 84 | Name: name, 85 | Description: description, 86 | Repos: repoReqs, 87 | Members: []string{}, 88 | Contributors: []string{}, 89 | } 90 | 91 | payload, err := json.Marshal(req) 92 | if err != nil { 93 | return nil, nil, fmt.Errorf("error marshaling request: %w", err) 94 | } 95 | 96 | httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(payload)) 97 | if err != nil { 98 | return nil, nil, fmt.Errorf("error creating request: %w", err) 99 | } 100 | 101 | httpReq.Header.Set("Authorization", "Bearer "+token) 102 | httpReq.Header.Set("Content-Type", "application/json") 103 | httpReq.Header.Set("Accept", "application/json") 104 | 105 | resp, err := s.httpClient.Do(httpReq) 106 | if err != nil { 107 | return nil, resp, fmt.Errorf("error making request: %w", err) 108 | } 109 | defer resp.Body.Close() 110 | 111 | if resp.StatusCode != http.StatusCreated { 112 | return nil, resp, fmt.Errorf("API request failed with status code: %d", resp.StatusCode) 113 | } 114 | 115 | var createdWorkspace DbWorkspace 116 | if err := json.NewDecoder(resp.Body).Decode(&createdWorkspace); err != nil { 117 | return nil, resp, fmt.Errorf("error decoding response: %w", err) 118 | } 119 | 120 | return &createdWorkspace, resp, nil 121 | } 122 | -------------------------------------------------------------------------------- /api/services/workspaces/workspaces_test.go: -------------------------------------------------------------------------------- 1 | package workspaces 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/open-sauced/pizza-cli/v2/api/mock" 14 | "github.com/open-sauced/pizza-cli/v2/api/services" 15 | ) 16 | 17 | func TestGetWorkspaces(t *testing.T) { 18 | t.Parallel() 19 | m := mock.NewMockRoundTripper(func(req *http.Request) (*http.Response, error) { 20 | assert.Equal(t, "https://api.example.com/v2/workspaces?limit=30&page=1", req.URL.String()) 21 | assert.Equal(t, "GET", req.Method) 22 | 23 | mockResponse := DbWorkspacesResponse{ 24 | Data: []DbWorkspace{ 25 | { 26 | ID: "abc123", 27 | Name: "workspace1", 28 | }, 29 | { 30 | ID: "xyz987", 31 | Name: "workspace2", 32 | }, 33 | }, 34 | Meta: services.MetaData{ 35 | Page: 1, 36 | Limit: 30, 37 | ItemCount: 2, 38 | PageCount: 1, 39 | HasPreviousPage: false, 40 | HasNextPage: false, 41 | }, 42 | } 43 | 44 | // Convert the mock response to JSON 45 | responseBody, _ := json.Marshal(mockResponse) 46 | 47 | // Return the mock response 48 | return &http.Response{ 49 | StatusCode: http.StatusOK, 50 | Body: io.NopCloser(bytes.NewBuffer(responseBody)), 51 | }, nil 52 | }) 53 | 54 | client := &http.Client{Transport: m} 55 | service := NewWorkspacesService(client, "https://api.example.com") 56 | 57 | workspaces, resp, err := service.GetWorkspaces("token", 1, 30) 58 | 59 | require.NoError(t, err) 60 | assert.NotNil(t, workspaces) 61 | assert.Equal(t, http.StatusOK, resp.StatusCode) 62 | assert.Len(t, workspaces.Data, 2) 63 | 64 | // First workspace 65 | assert.Equal(t, "abc123", workspaces.Data[0].ID) 66 | assert.Equal(t, "workspace1", workspaces.Data[0].Name) 67 | 68 | // Second workspace 69 | assert.Equal(t, "xyz987", workspaces.Data[1].ID) 70 | assert.Equal(t, "workspace2", workspaces.Data[1].Name) 71 | 72 | // Check the meta information 73 | assert.Equal(t, 1, workspaces.Meta.Page) 74 | assert.Equal(t, 30, workspaces.Meta.Limit) 75 | assert.Equal(t, 2, workspaces.Meta.ItemCount) 76 | assert.Equal(t, 1, workspaces.Meta.PageCount) 77 | assert.False(t, workspaces.Meta.HasPreviousPage) 78 | assert.False(t, workspaces.Meta.HasNextPage) 79 | } 80 | 81 | func TestCreateWorkspaceForUser(t *testing.T) { 82 | t.Parallel() 83 | m := mock.NewMockRoundTripper(func(req *http.Request) (*http.Response, error) { 84 | assert.Equal(t, "https://api.example.com/v2/workspaces", req.URL.String()) 85 | assert.Equal(t, "POST", req.Method) 86 | 87 | mockResponse := DbWorkspace{ 88 | ID: "abc123", 89 | Name: "workspace1", 90 | } 91 | 92 | // Convert the mock response to JSON 93 | responseBody, _ := json.Marshal(mockResponse) 94 | 95 | // Return the mock response 96 | return &http.Response{ 97 | StatusCode: http.StatusCreated, 98 | Body: io.NopCloser(bytes.NewBuffer(responseBody)), 99 | }, nil 100 | }) 101 | 102 | client := &http.Client{Transport: m} 103 | service := NewWorkspacesService(client, "https://api.example.com") 104 | 105 | workspace, resp, err := service.CreateWorkspaceForUser("token", "test workspace", "a workspace for testing", []string{}) 106 | 107 | require.NoError(t, err) 108 | assert.NotNil(t, workspace) 109 | assert.Equal(t, http.StatusCreated, resp.StatusCode) 110 | assert.Equal(t, "abc123", workspace.ID) 111 | assert.Equal(t, "workspace1", workspace.Name) 112 | } 113 | -------------------------------------------------------------------------------- /api/utils/validators.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | // IsValidRange ensures that an API range input is within the valid range of 4 | // acceptable ranges 5 | func IsValidRange(period int) bool { 6 | return period == 7 || period == 30 || period == 90 7 | } 8 | -------------------------------------------------------------------------------- /cmd/auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/open-sauced/pizza-cli/v2/api/auth" 9 | "github.com/open-sauced/pizza-cli/v2/pkg/constants" 10 | "github.com/open-sauced/pizza-cli/v2/pkg/utils" 11 | ) 12 | 13 | // Options are the persistent options for the login command 14 | type Options struct { 15 | // telemetry for capturing CLI events via PostHog 16 | telemetry *utils.PosthogCliClient 17 | } 18 | 19 | const ( 20 | loginLongDesc = `Log into the OpenSauced CLI. 21 | 22 | This command initiates the GitHub auth flow to log you into the OpenSauced CLI 23 | by launching your browser and logging in with GitHub.` 24 | ) 25 | 26 | func NewLoginCommand() *cobra.Command { 27 | opts := &Options{} 28 | 29 | cmd := &cobra.Command{ 30 | Use: "login", 31 | Short: "Log into the CLI via GitHub", 32 | Long: loginLongDesc, 33 | Args: cobra.NoArgs, 34 | RunE: func(cmd *cobra.Command, _ []string) error { 35 | disableTelem, _ := cmd.Flags().GetBool(constants.FlagNameTelemetry) 36 | 37 | opts.telemetry = utils.NewPosthogCliClient(!disableTelem) 38 | 39 | username, err := run() 40 | 41 | if err != nil { 42 | _ = opts.telemetry.CaptureFailedLogin() 43 | } else { 44 | _ = opts.telemetry.CaptureLogin(username) 45 | } 46 | 47 | _ = opts.telemetry.Done() 48 | 49 | return err 50 | }, 51 | } 52 | 53 | return cmd 54 | } 55 | 56 | func run() (string, error) { 57 | authenticator := auth.NewAuthenticator() 58 | 59 | username, err := authenticator.Login() 60 | if err != nil { 61 | return "", fmt.Errorf("sad: %w", err) 62 | } 63 | 64 | fmt.Println("🎉 Login successful 🎉") 65 | fmt.Println("Welcome aboard", username, "🍕") 66 | 67 | return username, nil 68 | } 69 | -------------------------------------------------------------------------------- /cmd/docs/docs.go: -------------------------------------------------------------------------------- 1 | package docs 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/cobra/doc" 10 | ) 11 | 12 | type Options struct { 13 | Path string 14 | } 15 | 16 | const DefaultPath = "./docs" 17 | 18 | func GetDocsPath(path string) (string, error) { 19 | if path == "" { 20 | fmt.Printf("No path was provided. Using default path: %s\n", DefaultPath) 21 | path = DefaultPath 22 | } 23 | 24 | absPath, err := filepath.Abs(path) 25 | 26 | if err != nil { 27 | return "", fmt.Errorf("error resolving absolute path: %w", err) 28 | } 29 | 30 | _, err = os.Stat(absPath) 31 | 32 | if os.IsNotExist(err) { 33 | fmt.Printf("The directory %s does not exist. Creating it...\n", absPath) 34 | if err := os.MkdirAll(absPath, os.ModePerm); err != nil { 35 | return "", fmt.Errorf("error creating directory %s: %w", absPath, err) 36 | } 37 | } else if err != nil { 38 | return "", fmt.Errorf("error checking directory %s: %w", absPath, err) 39 | } 40 | 41 | return absPath, nil 42 | } 43 | 44 | func GenerateDocumentation(rootCmd *cobra.Command, path string) error { 45 | fmt.Printf("Generating documentation in %s...\n", path) 46 | err := doc.GenMarkdownTree(rootCmd, path) 47 | 48 | if err != nil { 49 | return err 50 | } 51 | 52 | fmt.Printf("Finished generating documentation in %s\n", path) 53 | 54 | return nil 55 | } 56 | 57 | func NewDocsCommand() *cobra.Command { 58 | return &cobra.Command{ 59 | Use: "docs [path]", 60 | Short: "Generates the documentation for the CLI", 61 | Args: cobra.MaximumNArgs(1), 62 | RunE: func(cmd *cobra.Command, args []string) error { 63 | cmd.Parent().Root().DisableAutoGenTag = true 64 | 65 | var path string 66 | if len(args) > 0 { 67 | path = args[0] 68 | } 69 | 70 | resolvedPath, err := GetDocsPath(path) 71 | if err != nil { 72 | return err 73 | } 74 | 75 | return GenerateDocumentation(cmd.Parent().Root(), resolvedPath) 76 | }, 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /cmd/docs/docs_test.go: -------------------------------------------------------------------------------- 1 | package docs 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | ) 8 | 9 | func TestGetDocsPath(t *testing.T) { 10 | t.Parallel() 11 | 12 | t.Run("No path provided", func(t *testing.T) { 13 | t.Parallel() 14 | actual, err := GetDocsPath("") 15 | 16 | if err != nil { 17 | t.Errorf("GetDocsPath() error = %v, wantErr false", err) 18 | return 19 | } 20 | 21 | expected, _ := filepath.Abs(DefaultPath) 22 | if actual != expected { 23 | t.Errorf("GetDocsPath() = %v, want %v", actual, expected) 24 | } 25 | }) 26 | 27 | t.Run("With path provided", func(t *testing.T) { 28 | t.Parallel() 29 | inputPath := filepath.Join(os.TempDir(), "docs") 30 | actual, err := GetDocsPath(inputPath) 31 | 32 | if err != nil { 33 | t.Errorf("GetDocsPath() error = %v, wantErr false", err) 34 | return 35 | } 36 | 37 | expected, _ := filepath.Abs(inputPath) 38 | if actual != expected { 39 | t.Errorf("GetDocsPath() = %v, want %v", actual, expected) 40 | } 41 | 42 | if _, err := os.Stat(actual); os.IsNotExist(err) { 43 | t.Errorf("GetDocsPath() failed to create directory %s", actual) 44 | } 45 | }) 46 | 47 | t.Run("Invalid path", func(t *testing.T) { 48 | t.Parallel() 49 | invalidPath := string([]byte{0}) 50 | 51 | _, err := GetDocsPath(invalidPath) 52 | 53 | if err == nil { 54 | t.Errorf("GetDocsPath() error = nil, wantErr true") 55 | } 56 | }) 57 | } 58 | 59 | func TestGetDocsPath_ExistingDirectory(t *testing.T) { 60 | t.Parallel() 61 | 62 | tempDir, err := os.MkdirTemp("", "docs_test_existing") 63 | if err != nil { 64 | t.Fatalf("Failed to create temp dir: %v", err) 65 | } 66 | 67 | actual, err := GetDocsPath(tempDir) 68 | 69 | if err != nil { 70 | t.Errorf("GetDocsPath() error = %v, wantErr false", err) 71 | return 72 | } 73 | 74 | expected, _ := filepath.Abs(tempDir) 75 | if actual != expected { 76 | t.Errorf("GetDocsPath() = %v, want %v", actual, expected) 77 | } 78 | 79 | if _, err := os.Stat(actual); os.IsNotExist(err) { 80 | t.Errorf("GetDocsPath() failed to recognize existing directory %s", actual) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /cmd/generate/codeowners/codeowners.go: -------------------------------------------------------------------------------- 1 | package codeowners 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/go-git/go-git/v5" 10 | "github.com/jpmcb/gopherlogs" 11 | "github.com/jpmcb/gopherlogs/pkg/colors" 12 | "github.com/spf13/cobra" 13 | 14 | "github.com/open-sauced/pizza-cli/v2/pkg/config" 15 | "github.com/open-sauced/pizza-cli/v2/pkg/constants" 16 | "github.com/open-sauced/pizza-cli/v2/pkg/logging" 17 | "github.com/open-sauced/pizza-cli/v2/pkg/utils" 18 | ) 19 | 20 | // Options for the codeowners generation command 21 | type Options struct { 22 | // the path to the git repository on disk to generate a codeowners file for 23 | path string 24 | 25 | // whether to generate an agnostic "OWENRS" style codeowners file. 26 | // The default should be to generate a GitHub style "CODEOWNERS" file. 27 | ownersStyleFile bool 28 | 29 | // where the output file will go 30 | outputPath string 31 | 32 | // the number of days to look back 33 | previousDays int 34 | 35 | logger gopherlogs.Logger 36 | tty bool 37 | loglevel int 38 | 39 | // telemetry for capturing CLI events via PostHog 40 | telemetry *utils.PosthogCliClient 41 | 42 | config *config.Spec 43 | configLoadedPath string 44 | } 45 | 46 | const codeownersLongDesc string = `Generates a CODEOWNERS file for a given git repository. The generated file specifies up to 3 owners for EVERY file in the git tree based on the number of lines touched in that specific file over the specified range of time. 47 | 48 | Configuration: 49 | The command requires a .sauced.yaml file for accurate attribution. This file maps 50 | commit email addresses to GitHub usernames. The command looks for this file in two locations: 51 | 52 | 1. In the root of the specified repository path 53 | 2. In the user's home directory (~/.sauced.yaml) if not found in the repository 54 | 55 | If you run the command on a specific path, it will first look for .sauced.yaml in that 56 | path. If not found, it will fall back to ~/.sauced.yaml.` 57 | 58 | func NewCodeownersCommand() *cobra.Command { 59 | opts := &Options{} 60 | 61 | cmd := &cobra.Command{ 62 | Use: "codeowners path/to/repo [flags]", 63 | Short: "Generate a CODEOWNERS file for a GitHub repository using a \"~/.sauced.yaml\" config", 64 | Long: codeownersLongDesc, 65 | Example: ` 66 | # Generate CODEOWNERS file for the current directory 67 | pizza generate codeowners . 68 | 69 | # Generate CODEOWNERS file for a specific repository 70 | pizza generate codeowners /path/to/your/repo 71 | 72 | # Generate CODEOWNERS file analyzing the last 180 days 73 | pizza generate codeowners . --range 180 74 | 75 | # Generate an OWNERS style file instead of CODEOWNERS 76 | pizza generate codeowners . --owners-style-file 77 | 78 | # Specify a custom location for the .sauced.yaml file 79 | pizza generate codeowners . --config /path/to/.sauced.yaml 80 | 81 | # Specify a custom output location for the CODEOWNERS file 82 | pizza generate codeowners . --output-path /path/to/directory 83 | `, 84 | Args: func(_ *cobra.Command, args []string) error { 85 | if len(args) != 1 { 86 | return errors.New("you must provide exactly one argument: the path to the repository") 87 | } 88 | 89 | path := args[0] 90 | 91 | // Validate that the path is a real path on disk and accessible by the user 92 | absPath, err := filepath.Abs(path) 93 | if err != nil { 94 | return err 95 | } 96 | 97 | if _, err := os.Stat(absPath); os.IsNotExist(err) { 98 | return fmt.Errorf("the provided path does not exist: %w", err) 99 | } 100 | 101 | opts.path = absPath 102 | return nil 103 | }, 104 | RunE: func(cmd *cobra.Command, _ []string) error { 105 | var err error 106 | 107 | disableTelem, _ := cmd.Flags().GetBool(constants.FlagNameTelemetry) 108 | 109 | opts.telemetry = utils.NewPosthogCliClient(!disableTelem) 110 | 111 | configPath, _ := cmd.Flags().GetString("config") 112 | if configPath == "" { 113 | configPath = filepath.Join(opts.path, ".sauced.yaml") 114 | } 115 | 116 | opts.config, opts.configLoadedPath, err = config.LoadConfig(configPath) 117 | if err != nil { 118 | return err 119 | } 120 | 121 | opts.ownersStyleFile, _ = cmd.Flags().GetBool("owners-style-file") 122 | opts.outputPath, _ = cmd.Flags().GetString("output-path") 123 | 124 | // Default the outputPath to the base path if no flag value is given 125 | if opts.outputPath == "" { 126 | opts.outputPath = opts.path 127 | } 128 | 129 | opts.previousDays, _ = cmd.Flags().GetInt("range") 130 | opts.tty, _ = cmd.Flags().GetBool("tty-disable") 131 | 132 | loglevelS, _ := cmd.Flags().GetString("log-level") 133 | 134 | switch loglevelS { 135 | case "error": 136 | opts.loglevel = logging.LogError 137 | case "warn": 138 | opts.loglevel = logging.LogWarn 139 | case "info": 140 | opts.loglevel = logging.LogInfo 141 | case "debug": 142 | opts.loglevel = logging.LogDebug 143 | } 144 | 145 | err = run(opts, cmd) 146 | 147 | _ = opts.telemetry.Done() 148 | 149 | return err 150 | }, 151 | } 152 | 153 | cmd.PersistentFlags().IntP("range", "r", 90, "The number of days to analyze commit history (default 90)") 154 | cmd.PersistentFlags().Bool("owners-style-file", false, "Generate an agnostic OWNERS style file instead of CODEOWNERS.") 155 | cmd.PersistentFlags().StringP("output-path", "o", "", "Directory to create the output file.") 156 | 157 | return cmd 158 | } 159 | 160 | func run(opts *Options, cmd *cobra.Command) error { 161 | var err error 162 | opts.logger, err = gopherlogs.NewLogger( 163 | gopherlogs.WithLogVerbosity(opts.loglevel), 164 | gopherlogs.WithTty(!opts.tty), 165 | ) 166 | if err != nil { 167 | return fmt.Errorf("could not build logger: %w", err) 168 | } 169 | opts.logger.V(logging.LogDebug).Style(0, colors.FgBlue).Infof("Built logger with log level: %d\n", opts.loglevel) 170 | opts.logger.V(logging.LogDebug).Style(0, colors.FgBlue).Infof("Loaded config from: %s\n", opts.configLoadedPath) 171 | 172 | repo, err := git.PlainOpen(opts.path) 173 | if err != nil { 174 | _ = opts.telemetry.CaptureFailedCodeownersGenerate() 175 | return fmt.Errorf("error opening repo: %w", err) 176 | } 177 | opts.logger.V(logging.LogDebug).Style(0, colors.FgBlue).Infof("Opened repo at: %s\n", opts.path) 178 | 179 | processOptions := ProcessOptions{ 180 | repo, 181 | opts.previousDays, 182 | opts.path, 183 | opts.logger, 184 | } 185 | opts.logger.V(logging.LogDebug).Style(0, colors.FgBlue).Infof("Looking back %d days\n", opts.previousDays) 186 | 187 | codeowners, err := processOptions.process() 188 | if err != nil { 189 | _ = opts.telemetry.CaptureFailedCodeownersGenerate() 190 | return fmt.Errorf("error traversing git log: %w", err) 191 | } 192 | 193 | // Define which file to generate based on a flag 194 | var fileType string 195 | if opts.ownersStyleFile { 196 | fileType = "OWNERS" 197 | } else { 198 | fileType = "CODEOWNERS" 199 | } 200 | 201 | opts.logger.V(logging.LogDebug).Style(0, colors.FgBlue).Infof("Processing codeowners file at: %s\n", opts.outputPath) 202 | 203 | err = generateOutputFile(codeowners, filepath.Join(opts.outputPath, fileType), opts, cmd) 204 | if err != nil { 205 | _ = opts.telemetry.CaptureFailedCodeownersGenerate() 206 | return fmt.Errorf("error generating github style codeowners file: %w", err) 207 | } 208 | 209 | opts.logger.V(logging.LogInfo).Style(0, colors.FgGreen).Infof("Finished generating file: %s\n", filepath.Join(opts.outputPath, fileType)) 210 | _ = opts.telemetry.CaptureCodeownersGenerate() 211 | 212 | opts.logger.V(logging.LogInfo).Style(0, colors.FgCyan).Infof("\nCreate an OpenSauced Contributor Insight to get metrics and insights on these codeowners:\n") 213 | opts.logger.V(logging.LogInfo).Style(0, colors.FgCyan).Infof("$ pizza generate insight " + opts.path + "\n") 214 | _ = opts.telemetry.CaptureCodeownersGenerateContributorInsight() 215 | 216 | return nil 217 | } 218 | -------------------------------------------------------------------------------- /cmd/generate/codeowners/output.go: -------------------------------------------------------------------------------- 1 | package codeowners 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "regexp" 8 | "sort" 9 | "strings" 10 | 11 | "github.com/spf13/cobra" 12 | "github.com/spf13/pflag" 13 | 14 | "github.com/open-sauced/pizza-cli/v2/pkg/config" 15 | ) 16 | 17 | func generateOutputFile(fileStats FileStats, outputPath string, opts *Options, cmd *cobra.Command) error { 18 | 19 | // Create specified output directories if necessary 20 | err := os.MkdirAll(filepath.Dir(outputPath), os.ModePerm) 21 | if err != nil { 22 | if !os.IsExist(err) { 23 | return fmt.Errorf("error creating directory at %s filepath: %w", outputPath, err) 24 | } 25 | } 26 | 27 | // Open the file for writing 28 | file, err := os.Create(outputPath) 29 | if err != nil { 30 | return fmt.Errorf("error creating %s file: %w", outputPath, err) 31 | } 32 | defer file.Close() 33 | var flags []string 34 | 35 | cmd.Flags().Visit(func(f *pflag.Flag) { 36 | flags = append(flags, fmt.Sprintf("--%s %s", f.Name, f.Value.String())) 37 | }) 38 | generatedCommand := fmt.Sprintf("# $ pizza generate codeowners %s/", filepath.Base(opts.path)) 39 | if len(flags) > 0 { 40 | generatedCommand += " " 41 | generatedCommand += strings.Join(flags, " ") 42 | } 43 | 44 | // Write the header 45 | _, err = file.WriteString(fmt.Sprintf("# This file is generated automatically by OpenSauced pizza-cli. DO NOT EDIT. Stay saucy!\n#\n# Generated with command:\n%s\n\n", generatedCommand)) 46 | 47 | if err != nil { 48 | return fmt.Errorf("error writing to %s file: %w", outputPath, err) 49 | } 50 | 51 | // Sort the filenames to ensure consistent output 52 | var filenames []string 53 | for filename := range fileStats { 54 | filenames = append(filenames, filename) 55 | } 56 | sort.Strings(filenames) 57 | 58 | // Process each file 59 | for _, filename := range filenames { 60 | authorStats := fileStats[filename] 61 | if opts.ownersStyleFile { 62 | err = writeOwnersChunk(authorStats, opts.config, file, filename, outputPath) 63 | if err != nil { 64 | return fmt.Errorf("error writing to %s file: %w", outputPath, err) 65 | } 66 | } else { 67 | _, err := writeGitHubCodeownersChunk(authorStats, opts.config, file, filename, outputPath) 68 | if err != nil { 69 | return fmt.Errorf("error writing to %s file: %w", outputPath, err) 70 | } 71 | } 72 | } 73 | 74 | return nil 75 | } 76 | 77 | func writeGitHubCodeownersChunk(authorStats AuthorStats, config *config.Spec, file *os.File, srcFilename string, outputPath string) ([]string, error) { 78 | topContributors := getTopContributorAttributions(authorStats, 3, config) 79 | 80 | resultSlice := []string{} 81 | for _, contributor := range topContributors { 82 | resultSlice = append(resultSlice, contributor.GitHubAlias) 83 | } 84 | 85 | if len(topContributors) > 0 { 86 | _, err := fmt.Fprintf(file, "%s @%s\n", cleanFilename(srcFilename), strings.Join(resultSlice, " @")) 87 | if err != nil { 88 | return nil, fmt.Errorf("error writing to %s file: %w", outputPath, err) 89 | } 90 | } else { 91 | // no code owners to attribute to file 92 | _, err := fmt.Fprintf(file, "%s\n", cleanFilename(srcFilename)) 93 | if err != nil { 94 | return nil, fmt.Errorf("error writing to %s file: %w", outputPath, err) 95 | } 96 | } 97 | 98 | return resultSlice, nil 99 | } 100 | 101 | func writeOwnersChunk(authorStats AuthorStats, config *config.Spec, file *os.File, srcFilename string, outputPath string) error { 102 | topContributors := getTopContributorAttributions(authorStats, 3, config) 103 | 104 | _, err := fmt.Fprintf(file, "%s\n", srcFilename) 105 | if err != nil { 106 | return fmt.Errorf("error writing to %s file: %w", outputPath, err) 107 | } 108 | 109 | for i := 0; i < len(topContributors) && i < 3; i++ { 110 | _, err = fmt.Fprintf(file, " - %s\n", topContributors[i].Name) 111 | if err != nil { 112 | return fmt.Errorf("error writing to %s file: %w", outputPath, err) 113 | } 114 | 115 | _, err = fmt.Fprintf(file, " - %s\n", topContributors[i].Email) 116 | if err != nil { 117 | return fmt.Errorf("error writing to %s file: %w", outputPath, err) 118 | } 119 | } 120 | 121 | return nil 122 | } 123 | 124 | func getTopContributorAttributions(authorStats AuthorStats, n int, config *config.Spec) AuthorStatSlice { 125 | sortedAuthorStats := authorStats.ToSortedSlice() 126 | 127 | // Get top n contributors (or all if less than n) 128 | var topContributors AuthorStatSlice 129 | 130 | for i := 0; i < len(sortedAuthorStats) && i < n; i++ { 131 | // get attributions for email / github handles 132 | for username, emails := range config.Attributions { 133 | for _, email := range emails { 134 | if email == sortedAuthorStats[i].Email { 135 | sortedAuthorStats[i].GitHubAlias = username 136 | topContributors = append(topContributors, sortedAuthorStats[i]) 137 | } 138 | } 139 | } 140 | } 141 | 142 | if len(topContributors) == 0 { 143 | for _, fallbackAttribution := range config.AttributionFallback { 144 | topContributors = append(topContributors, &CodeownerStat{ 145 | GitHubAlias: fallbackAttribution, 146 | }) 147 | } 148 | } 149 | 150 | return topContributors 151 | } 152 | 153 | func cleanFilename(filename string) string { 154 | // Split the filename in case its rename, see https://github.com/open-sauced/pizza-cli/issues/101 155 | parsedFilename := strings.Split(filename, " ")[0] 156 | // Replace anything that is not a word, period, single quote, dash, space, forward slash, or backslash with an escaped version 157 | re := regexp.MustCompile(`([^\w\.\'\-\s\/\\])`) 158 | escapedFilename := re.ReplaceAllString(parsedFilename, "\\$0") 159 | 160 | return escapedFilename 161 | } 162 | -------------------------------------------------------------------------------- /cmd/generate/codeowners/output_test.go: -------------------------------------------------------------------------------- 1 | package codeowners 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/open-sauced/pizza-cli/v2/pkg/config" 9 | ) 10 | 11 | func TestCleanFilename(testRunner *testing.T) { 12 | var tests = []struct { 13 | name string 14 | input string 15 | expected string 16 | }{ 17 | {"path/to/(home).go", "path/to/(home).go", `path/to/\(home\).go`}, 18 | {"path/to/[home].go", "path/to/[home].go", `path/to/\[home\].go`}, 19 | {"path/to/+page.go", "path/to/+page.go", `path/to/\+page.go`}, 20 | {"path/to/go-home.go", "path/to/go-home.go", `path/to/go-home.go`}, 21 | {`path\to\(home).go`, `path\to\(home).go`, `path\to\\(home\).go`}, 22 | {`path\to\[home].go`, `path\to\[home].go`, `path\to\\[home\].go`}, 23 | {`path\to\+page.go`, `path\to\+page.go`, `path\to\\+page.go`}, 24 | {`path\to\go-home.go`, `path\to\go-home.go`, `path\to\go-home.go`}, 25 | } 26 | 27 | for _, testItem := range tests { 28 | testRunner.Run(testItem.name, func(tester *testing.T) { 29 | ans := cleanFilename(testItem.input) 30 | if ans != testItem.expected { 31 | tester.Errorf("got %s, expected %s", ans, testItem.expected) 32 | } 33 | }) 34 | } 35 | } 36 | 37 | func TestGetTopContributorAttributions(testRunner *testing.T) { 38 | configSpec := config.Spec{ 39 | Attributions: map[string][]string{ 40 | "brandonroberts": {"brandon@opensauced.pizza"}, 41 | }, 42 | AttributionFallback: []string{"open-sauced/engineering"}, 43 | } 44 | 45 | var authorStats = AuthorStats{ 46 | "brandon": {GitHubAlias: "brandon", Email: "brandon@opensauced.pizza", Lines: 20}, 47 | "john": {GitHubAlias: "john", Email: "john@opensauced.pizza", Lines: 15}, 48 | } 49 | 50 | results := getTopContributorAttributions(authorStats, 3, &configSpec) 51 | 52 | assert.Len(testRunner, results, 1, "Expected 1 result") 53 | assert.Equal(testRunner, "brandonroberts", results[0].GitHubAlias, "Expected brandonroberts") 54 | } 55 | 56 | func TestGetFallbackAttributions(testRunner *testing.T) { 57 | configSpec := config.Spec{ 58 | Attributions: map[string][]string{ 59 | "jpmcb": {"jpmcb@opensauced.pizza"}, 60 | "brandonroberts": {"brandon@opensauced.pizza"}, 61 | }, 62 | AttributionFallback: []string{"open-sauced/engineering"}, 63 | } 64 | 65 | results := getTopContributorAttributions(AuthorStats{}, 3, &configSpec) 66 | 67 | assert.Len(testRunner, results, 1, "Expected 1 result") 68 | assert.Equal(testRunner, "open-sauced/engineering", results[0].GitHubAlias, "Expected open-sauced/engineering") 69 | } 70 | -------------------------------------------------------------------------------- /cmd/generate/codeowners/spec.go: -------------------------------------------------------------------------------- 1 | package codeowners 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | 7 | "github.com/go-git/go-git/v5/plumbing/object" 8 | ) 9 | 10 | // FileStats is a mapping of filenames to author stats. 11 | // Example: { "path/to/file": { Author stats }} 12 | type FileStats map[string]AuthorStats 13 | 14 | func (fs FileStats) addStat(filestat *object.FileStat, commit *object.Commit) { 15 | author := fmt.Sprintf("%s <%s>", commit.Author.Name, commit.Author.Email) 16 | filename := filestat.Name 17 | 18 | if _, ok := fs[filename]; !ok { 19 | fs[filename] = make(AuthorStats) 20 | } 21 | 22 | if _, ok := fs[filename][author]; !ok { 23 | fs[filename][author] = &CodeownerStat{ 24 | Name: commit.Author.Name, 25 | Email: commit.Author.Email, 26 | } 27 | } 28 | 29 | fs[filename][author].Lines += filestat.Addition + filestat.Deletion 30 | } 31 | 32 | // AuthorStats is a mapping of author name email combinations to codeowner stats. 33 | // Example: { "First Last name@domain.com": { Codeowner stat }} 34 | type AuthorStats map[string]*CodeownerStat 35 | 36 | // CodeownerStat is the base struct of name, email, lines changed, 37 | // and the configured GitHub alias for a given codeowner. This is derived from 38 | // git reflog commits and diffs. 39 | type CodeownerStat struct { 40 | Name string 41 | Email string 42 | Lines int 43 | GitHubAlias string 44 | } 45 | 46 | // AuthorStatSlice is a slice of codeowner stats. This is a utility type that makes 47 | // turning a mapping of author stats to slices easy. 48 | type AuthorStatSlice []*CodeownerStat 49 | 50 | func (as AuthorStats) ToSortedSlice() AuthorStatSlice { 51 | slice := make(AuthorStatSlice, 0, len(as)) 52 | 53 | for _, stat := range as { 54 | slice = append(slice, stat) 55 | } 56 | 57 | sort.Slice(slice, func(i, j int) bool { 58 | // sort the author stats by descending number of lines 59 | return slice[i].Lines > slice[j].Lines 60 | }) 61 | 62 | return slice 63 | } 64 | -------------------------------------------------------------------------------- /cmd/generate/codeowners/traversal.go: -------------------------------------------------------------------------------- 1 | package codeowners 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "path/filepath" 7 | "strings" 8 | "time" 9 | 10 | "github.com/go-git/go-git/v5" 11 | "github.com/go-git/go-git/v5/plumbing/object" 12 | "github.com/go-git/go-git/v5/plumbing/storer" 13 | "github.com/jpmcb/gopherlogs" 14 | "github.com/jpmcb/gopherlogs/pkg/colors" 15 | 16 | "github.com/open-sauced/pizza-cli/v2/pkg/logging" 17 | ) 18 | 19 | // ProcessOptions are the options for iterating a git reflog and deriving the codeowners 20 | type ProcessOptions struct { 21 | repo *git.Repository 22 | previousDays int 23 | dirPath string 24 | 25 | logger gopherlogs.Logger 26 | } 27 | 28 | func (po *ProcessOptions) process() (FileStats, error) { 29 | fs := make(FileStats) 30 | 31 | // Get the HEAD reference 32 | head, err := po.repo.Head() 33 | if err != nil { 34 | return nil, fmt.Errorf("could not get repo head: %w", err) 35 | } 36 | 37 | now := time.Now() 38 | previousTime := now.AddDate(0, 0, -po.previousDays) 39 | 40 | // Get the commit history for all files 41 | commitIter, err := po.repo.Log(&git.LogOptions{ 42 | From: head.Hash(), 43 | Since: &previousTime, 44 | }) 45 | if err != nil { 46 | return nil, fmt.Errorf("could not get repo log iterator: %w", err) 47 | } 48 | 49 | defer commitIter.Close() 50 | 51 | ctx, cancel := context.WithCancel(context.Background()) 52 | go func(ctx context.Context) { 53 | po.logger.Style(0, colors.Reset).AnimateProgressWithOptions( 54 | gopherlogs.AnimatorWithContext(ctx), 55 | gopherlogs.AnimatorWithMaxLen(80), 56 | gopherlogs.AnimatorWithMessagef("Iterating commits for repo: %s ", po.dirPath), 57 | ) 58 | }(ctx) 59 | 60 | err = commitIter.ForEach(func(commit *object.Commit) error { 61 | // Get the patch for this commit between the head and the parent commit 62 | patch, err := po.getPatchForCommit(commit) 63 | if err != nil { 64 | return fmt.Errorf("could not get patch for commit %s: %w", commit.Hash, err) 65 | } 66 | 67 | for _, fileStat := range patch.Stats() { 68 | if !po.isSubPath(po.dirPath, fileStat.Name) { 69 | // Explicitly ignore paths that do not exist in the repo. 70 | // This is relevant for old changes and filename changes. 71 | // Example: this will ignore some/file/path => new/name/path 72 | // changes that ONLY change the name / path of a file. 73 | // 74 | // These are edge cases to revisit in the future. 75 | return nil 76 | } 77 | 78 | fs.addStat(&fileStat, commit) 79 | } 80 | 81 | return nil 82 | }) 83 | 84 | if err != nil { 85 | cancel() 86 | return nil, fmt.Errorf("could not process commit iterator: %w", err) 87 | } 88 | 89 | cancel() 90 | po.logger.V(logging.LogInfo).Style(0, colors.FgGreen).ReplaceLinef("Finished processing commits for: %s", po.dirPath) 91 | return fs, nil 92 | } 93 | 94 | func (po *ProcessOptions) isSubPath(basePath, relativePath string) bool { 95 | // Clean the paths to remove any '..' or '.' components 96 | basePath = filepath.Clean(basePath) 97 | fullPath := filepath.Join(basePath, relativePath) 98 | fullPath = filepath.Clean(fullPath) 99 | 100 | // Check if the full path starts with the base path 101 | return strings.HasPrefix(fullPath, basePath) 102 | } 103 | 104 | func (po *ProcessOptions) getPatchForCommit(commit *object.Commit) (*object.Patch, error) { 105 | // No parents (the initial, first commit). Use a stub of an object tree 106 | // to simulate "no" parent present in the diff 107 | if commit.NumParents() == 0 { 108 | parentTree := &object.Tree{} 109 | commitTree, err := commit.Tree() 110 | if err != nil { 111 | return nil, fmt.Errorf("could not get commit tree for commit %s: %w", commit.Hash, err) 112 | } 113 | 114 | return parentTree.Patch(commitTree) 115 | } 116 | 117 | commitTree, err := commit.Tree() 118 | if err != nil { 119 | return nil, fmt.Errorf("could not get commit tree for commit %s: %w", commit.Hash, err) 120 | } 121 | 122 | parentCommit, err := commit.Parents().Next() 123 | if err != nil && err != storer.ErrStop { 124 | return nil, fmt.Errorf("could not get parent commit to commit %s: %w", commit.Hash, err) 125 | } 126 | 127 | parentTree, err := parentCommit.Tree() 128 | if err != nil { 129 | return nil, fmt.Errorf("could not get parent commit tree for parent commit %s: %w", parentCommit.Hash, err) 130 | } 131 | 132 | return parentTree.Patch(commitTree) 133 | } 134 | -------------------------------------------------------------------------------- /cmd/generate/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "slices" 9 | "strings" 10 | "time" 11 | 12 | tea "github.com/charmbracelet/bubbletea" 13 | "github.com/go-git/go-git/v5" 14 | "github.com/go-git/go-git/v5/plumbing/object" 15 | "github.com/spf13/cobra" 16 | 17 | "github.com/open-sauced/pizza-cli/v2/pkg/constants" 18 | "github.com/open-sauced/pizza-cli/v2/pkg/utils" 19 | ) 20 | 21 | // Options for the config generation command 22 | type Options struct { 23 | // the path to the git repository on disk to generate a codeowners file for 24 | path string 25 | 26 | // where the '.sauced.yaml' file will go 27 | outputPath string 28 | 29 | // whether to use interactive mode 30 | isInteractive bool 31 | 32 | // number of days to look back 33 | previousDays int 34 | 35 | // from global config 36 | ttyDisabled bool 37 | 38 | // telemetry for capturing CLI events via PostHog 39 | telemetry *utils.PosthogCliClient 40 | } 41 | 42 | const configLongDesc string = `Generates a ".sauced.yaml" configuration file for use with the Pizza CLI's codeowners command. 43 | 44 | This command analyzes the git history of the current repository to create a mapping 45 | of email addresses to GitHub usernames. ` 46 | 47 | func NewConfigCommand() *cobra.Command { 48 | opts := &Options{} 49 | 50 | cmd := &cobra.Command{ 51 | Use: "config path/to/repo [flags]", 52 | Short: "Generates a \".sauced.yaml\" config based on the current repository", 53 | Long: configLongDesc, 54 | Args: func(_ *cobra.Command, args []string) error { 55 | if len(args) != 1 { 56 | return errors.New("you must provide exactly one argument: the path to the repository") 57 | } 58 | 59 | path := args[0] 60 | 61 | // Validate that the path is a real path on disk and accessible by the user 62 | absPath, err := filepath.Abs(path) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | if _, err := os.Stat(absPath); os.IsNotExist(err) { 68 | return fmt.Errorf("the provided path does not exist: %w", err) 69 | } 70 | 71 | opts.path = absPath 72 | return nil 73 | }, 74 | 75 | RunE: func(cmd *cobra.Command, _ []string) error { 76 | disableTelem, _ := cmd.Flags().GetBool(constants.FlagNameTelemetry) 77 | 78 | opts.telemetry = utils.NewPosthogCliClient(!disableTelem) 79 | opts.outputPath, _ = cmd.Flags().GetString("output-path") 80 | opts.isInteractive, _ = cmd.Flags().GetBool("interactive") 81 | opts.ttyDisabled, _ = cmd.Flags().GetBool("tty-disable") 82 | opts.previousDays, _ = cmd.Flags().GetInt("range") 83 | 84 | err := run(opts) 85 | _ = opts.telemetry.Done() 86 | 87 | return err 88 | }, 89 | } 90 | 91 | cmd.PersistentFlags().StringP("output-path", "o", "./", "Directory to create the `.sauced.yaml` file.") 92 | cmd.PersistentFlags().BoolP("interactive", "i", false, "Whether to be interactive") 93 | cmd.PersistentFlags().IntP("range", "r", 90, "The number of days to analyze commit history (default 90)") 94 | return cmd 95 | } 96 | 97 | func run(opts *Options) error { 98 | attributionMap := make(map[string][]string) 99 | 100 | // Open repo 101 | repo, err := git.PlainOpen(opts.path) 102 | if err != nil { 103 | _ = opts.telemetry.CaptureFailedConfigGenerate() 104 | return fmt.Errorf("error opening repo: %w", err) 105 | } 106 | 107 | commitIter, err := repo.CommitObjects() 108 | 109 | if err != nil { 110 | _ = opts.telemetry.CaptureFailedConfigGenerate() 111 | return fmt.Errorf("error opening repo commits: %w", err) 112 | } 113 | 114 | now := time.Now() 115 | previousTime := now.AddDate(0, 0, -opts.previousDays) 116 | 117 | var uniqueEmails []string 118 | err = commitIter.ForEach(func(c *object.Commit) error { 119 | name := c.Author.Name 120 | email := c.Author.Email 121 | 122 | if c.Author.When.Before(previousTime) { 123 | return nil 124 | } 125 | 126 | if strings.Contains(name, "[bot]") { 127 | return nil 128 | } 129 | 130 | if opts.ttyDisabled || !opts.isInteractive { 131 | doesEmailExist := slices.Contains(attributionMap[name], email) 132 | if !doesEmailExist { 133 | // AUTOMATIC: set every name and associated emails 134 | attributionMap[name] = append(attributionMap[name], email) 135 | } 136 | } else if !slices.Contains(uniqueEmails, email) { 137 | uniqueEmails = append(uniqueEmails, email) 138 | } 139 | return nil 140 | }) 141 | 142 | if err != nil { 143 | return fmt.Errorf("error iterating over repo commits: %w", err) 144 | } 145 | 146 | // INTERACTIVE: per unique email, set a name (existing or new or ignore) 147 | if opts.isInteractive && !opts.ttyDisabled { 148 | _ = opts.telemetry.CaptureConfigGenerateMode("interactive") 149 | program := tea.NewProgram(initialModel(opts, uniqueEmails)) 150 | if _, err := program.Run(); err != nil { 151 | _ = opts.telemetry.CaptureFailedConfigGenerate() 152 | return fmt.Errorf("error running interactive mode: %w", err) 153 | } 154 | } else { 155 | _ = opts.telemetry.CaptureConfigGenerateMode("automatic") 156 | // generate an output file 157 | // default: `./.sauced.yaml` 158 | // fallback for home directories 159 | if opts.outputPath == "~/" { 160 | homeDir, _ := os.UserHomeDir() 161 | err := generateOutputFile(filepath.Join(homeDir, ".sauced.yaml"), attributionMap) 162 | if err != nil { 163 | _ = opts.telemetry.CaptureFailedConfigGenerate() 164 | return fmt.Errorf("error generating output file: %w", err) 165 | } 166 | } else { 167 | err := generateOutputFile(filepath.Join(opts.outputPath, ".sauced.yaml"), attributionMap) 168 | if err != nil { 169 | _ = opts.telemetry.CaptureFailedConfigGenerate() 170 | return fmt.Errorf("error generating output file: %w", err) 171 | } 172 | } 173 | } 174 | 175 | _ = opts.telemetry.CaptureConfigGenerate() 176 | return nil 177 | } 178 | -------------------------------------------------------------------------------- /cmd/generate/config/output.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/open-sauced/pizza-cli/v2/pkg/config" 8 | "github.com/open-sauced/pizza-cli/v2/pkg/utils" 9 | ) 10 | 11 | func generateOutputFile(outputPath string, attributionMap map[string][]string) error { 12 | file, err := os.Create(outputPath) 13 | if err != nil { 14 | return fmt.Errorf("error creating %s file: %w", outputPath, err) 15 | } 16 | defer file.Close() 17 | 18 | // write the header preamble 19 | _, err = file.WriteString("# Configuration for attributing commits with emails to GitHub user profiles\n# Used during codeowners generation.\n\n# List the emails associated with the given username\n# The commits associated with these emails will be attributed to\n# the username in this yaml map. Any number of emails may be listed\n\n") 20 | 21 | if err != nil { 22 | return fmt.Errorf("error writing to %s file: %w", outputPath, err) 23 | } 24 | 25 | var config config.Spec 26 | config.Attributions = attributionMap 27 | 28 | // for pretty print test 29 | yaml, err := utils.OutputYAML(config) 30 | 31 | if err != nil { 32 | return fmt.Errorf("failed to turn into YAML: %w", err) 33 | } 34 | 35 | _, err = file.WriteString(yaml) 36 | 37 | if err != nil { 38 | return fmt.Errorf("failed to turn into YAML: %w", err) 39 | } 40 | 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /cmd/generate/config/spec.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/charmbracelet/bubbles/help" 10 | "github.com/charmbracelet/bubbles/key" 11 | "github.com/charmbracelet/bubbles/textinput" 12 | tea "github.com/charmbracelet/bubbletea" 13 | ) 14 | 15 | // Bubbletea for Interactive Mode 16 | 17 | type model struct { 18 | textInput textinput.Model 19 | help help.Model 20 | keymap keymap 21 | 22 | opts *Options 23 | attributionMap map[string][]string 24 | uniqueEmails []string 25 | currentIndex int 26 | } 27 | 28 | type keymap struct{} 29 | 30 | func (k keymap) ShortHelp() []key.Binding { 31 | return []key.Binding{ 32 | key.NewBinding(key.WithKeys("ctrl+n"), key.WithHelp("ctrl+n", "next suggestion")), 33 | key.NewBinding(key.WithKeys("ctrl+p"), key.WithHelp("ctrl+p", "prev suggestion")), 34 | key.NewBinding(key.WithKeys("ctrl+i"), key.WithHelp("ctrl+i", "ignore email")), 35 | key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "skip the rest")), 36 | key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "quit")), 37 | key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), 38 | } 39 | } 40 | 41 | func (k keymap) FullHelp() [][]key.Binding { 42 | return [][]key.Binding{k.ShortHelp()} 43 | } 44 | 45 | func initialModel(opts *Options, uniqueEmails []string) model { 46 | ti := textinput.New() 47 | ti.Placeholder = "username" 48 | ti.Focus() 49 | ti.ShowSuggestions = true 50 | 51 | return model{ 52 | textInput: ti, 53 | help: help.New(), 54 | keymap: keymap{}, 55 | 56 | opts: opts, 57 | attributionMap: make(map[string][]string), 58 | uniqueEmails: uniqueEmails, 59 | currentIndex: 0, 60 | } 61 | } 62 | 63 | func (m model) Init() tea.Cmd { 64 | return textinput.Blink 65 | } 66 | 67 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 68 | var cmd tea.Cmd 69 | currentEmail := m.uniqueEmails[m.currentIndex] 70 | 71 | existingUsers := make([]string, 0, len(m.attributionMap)) 72 | for k := range m.attributionMap { 73 | existingUsers = append(existingUsers, k) 74 | } 75 | 76 | m.textInput.SetSuggestions(existingUsers) 77 | 78 | keyMsg, ok := msg.(tea.KeyMsg) 79 | 80 | if ok { 81 | switch keyMsg.Type { 82 | case tea.KeyCtrlC, tea.KeyEsc: 83 | return m, tea.Quit 84 | 85 | case tea.KeyCtrlI: 86 | m.currentIndex++ 87 | if m.currentIndex+1 >= len(m.uniqueEmails) { 88 | return m, runOutputGeneration(m.opts, m.attributionMap) 89 | } 90 | return m, nil 91 | 92 | case tea.KeyCtrlS: 93 | return m, runOutputGeneration(m.opts, m.attributionMap) 94 | 95 | case tea.KeyEnter: 96 | if len(strings.Trim(m.textInput.Value(), " ")) == 0 { 97 | return m, nil 98 | } 99 | m.attributionMap[m.textInput.Value()] = append(m.attributionMap[m.textInput.Value()], currentEmail) 100 | m.textInput.Reset() 101 | if m.currentIndex+1 >= len(m.uniqueEmails) { 102 | return m, runOutputGeneration(m.opts, m.attributionMap) 103 | } 104 | 105 | m.currentIndex++ 106 | return m, nil 107 | } 108 | } 109 | 110 | m.textInput, cmd = m.textInput.Update(msg) 111 | 112 | return m, cmd 113 | } 114 | 115 | func (m model) View() string { 116 | currentEmail := "" 117 | if m.currentIndex < len(m.uniqueEmails) { 118 | currentEmail = m.uniqueEmails[m.currentIndex] 119 | } 120 | 121 | return fmt.Sprintf( 122 | "Found email %s - who to attribute to?: \n%s\n\n%s\n", 123 | currentEmail, 124 | m.textInput.View(), 125 | m.help.View(m.keymap), 126 | ) 127 | } 128 | 129 | func runOutputGeneration(opts *Options, attributionMap map[string][]string) tea.Cmd { 130 | // generate an output file 131 | // default: `./.sauced.yaml` 132 | // fallback for home directories 133 | return func() tea.Msg { 134 | if opts.outputPath == "~/" { 135 | homeDir, _ := os.UserHomeDir() 136 | err := generateOutputFile(filepath.Join(homeDir, ".sauced.yaml"), attributionMap) 137 | if err != nil { 138 | return fmt.Errorf("error generating output file: %w", err) 139 | } 140 | } else { 141 | err := generateOutputFile(filepath.Join(opts.outputPath, ".sauced.yaml"), attributionMap) 142 | if err != nil { 143 | return fmt.Errorf("error generating output file: %w", err) 144 | } 145 | } 146 | 147 | return tea.Quit() 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /cmd/generate/generate.go: -------------------------------------------------------------------------------- 1 | package generate 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/open-sauced/pizza-cli/v2/cmd/generate/codeowners" 9 | "github.com/open-sauced/pizza-cli/v2/cmd/generate/config" 10 | "github.com/open-sauced/pizza-cli/v2/cmd/generate/insight" 11 | ) 12 | 13 | const generateLongDesc string = `The 'generate' command provides tools to automate the creation of important project documentation and derive insights from your codebase.` 14 | 15 | func NewGenerateCommand() *cobra.Command { 16 | cmd := &cobra.Command{ 17 | Use: "generate [subcommand] [flags]", 18 | Short: "Generates documentation and insights from your codebase", 19 | Long: generateLongDesc, 20 | Args: func(_ *cobra.Command, args []string) error { 21 | if len(args) != 1 { 22 | return errors.New("you must provide a subcommand") 23 | } 24 | 25 | return nil 26 | }, 27 | RunE: run, 28 | } 29 | 30 | cmd.AddCommand(codeowners.NewCodeownersCommand()) 31 | cmd.AddCommand(config.NewConfigCommand()) 32 | cmd.AddCommand(insight.NewGenerateInsightCommand()) 33 | 34 | return cmd 35 | } 36 | 37 | func run(cmd *cobra.Command, _ []string) error { 38 | return cmd.Help() 39 | } 40 | -------------------------------------------------------------------------------- /cmd/insights/insights.go: -------------------------------------------------------------------------------- 1 | package insights 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/open-sauced/pizza-cli/v2/pkg/constants" 7 | ) 8 | 9 | // NewInsightsCommand returns a new cobra command for 'pizza insights' 10 | func NewInsightsCommand() *cobra.Command { 11 | cmd := &cobra.Command{ 12 | Use: "insights [flags]", 13 | Short: "Gather insights about git contributors, repositories, users and pull requests", 14 | Long: "Gather insights about git contributors, repositories, user and pull requests and display the results", 15 | RunE: func(cmd *cobra.Command, _ []string) error { 16 | return cmd.Help() 17 | }, 18 | } 19 | cmd.PersistentFlags().StringP(constants.FlagNameOutput, "o", constants.OutputTable, "The formatting for command output. One of: (table, yaml, csv, json)") 20 | cmd.AddCommand(NewContributorsCommand()) 21 | cmd.AddCommand(NewRepositoriesCommand()) 22 | cmd.AddCommand(NewUserContributionsCommand()) 23 | return cmd 24 | } 25 | -------------------------------------------------------------------------------- /cmd/insights/repositories.go: -------------------------------------------------------------------------------- 1 | package insights 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | "sync" 9 | 10 | bubblesTable "github.com/charmbracelet/bubbles/table" 11 | "github.com/spf13/cobra" 12 | 13 | "github.com/open-sauced/pizza-cli/v2/api" 14 | "github.com/open-sauced/pizza-cli/v2/api/services/contributors" 15 | "github.com/open-sauced/pizza-cli/v2/api/services/histogram" 16 | "github.com/open-sauced/pizza-cli/v2/pkg/constants" 17 | "github.com/open-sauced/pizza-cli/v2/pkg/utils" 18 | ) 19 | 20 | type repositoriesOptions struct { 21 | // APIClient is the http client for making calls to the open-sauced api 22 | APIClient *api.Client 23 | 24 | // Repos is the array of git repository urls 25 | Repos []string 26 | 27 | // FilePath is the path to yaml file containing an array of git repository urls 28 | FilePath string 29 | 30 | // RangeVal is the number of days, used for query filtering 31 | // Constrained to either 30 or 60 32 | RangeVal int 33 | 34 | // Output is the formatting style for command output 35 | Output string 36 | 37 | telemetry *utils.PosthogCliClient 38 | } 39 | 40 | // NewRepositoriesCommand returns a new cobra command for 'pizza insights repositories' 41 | func NewRepositoriesCommand() *cobra.Command { 42 | opts := &repositoriesOptions{} 43 | cmd := &cobra.Command{ 44 | Use: "repositories url... [flags]", 45 | Aliases: []string{"repos"}, 46 | Short: "Gather insights about indexed git repositories", 47 | Long: "Gather insights about indexed git repositories. This command will show info about contributors, pull requests, etc.", 48 | Args: func(cmd *cobra.Command, args []string) error { 49 | fileFlag := cmd.Flags().Lookup(constants.FlagNameFile) 50 | if !fileFlag.Changed && len(args) == 0 { 51 | return fmt.Errorf("must specify git repository url argument(s) or provide %s flag", fileFlag.Name) 52 | } 53 | opts.Repos = append(opts.Repos, args...) 54 | return nil 55 | }, 56 | RunE: func(cmd *cobra.Command, _ []string) error { 57 | disableTelem, _ := cmd.Flags().GetBool(constants.FlagNameTelemetry) 58 | 59 | opts.telemetry = utils.NewPosthogCliClient(!disableTelem) 60 | 61 | endpointURL, _ := cmd.Flags().GetString(constants.FlagNameEndpoint) 62 | opts.APIClient = api.NewClient(endpointURL) 63 | output, _ := cmd.Flags().GetString(constants.FlagNameOutput) 64 | opts.Output = output 65 | 66 | err := opts.run() 67 | 68 | if err != nil { 69 | _ = opts.telemetry.CaptureInsights() 70 | } else { 71 | _ = opts.telemetry.CaptureFailedInsights() 72 | } 73 | 74 | _ = opts.telemetry.Done() 75 | 76 | return err 77 | }, 78 | } 79 | cmd.Flags().StringVarP(&opts.FilePath, constants.FlagNameFile, "f", "", "Path to yaml file containing an array of git repository urls") 80 | cmd.Flags().IntVarP(&opts.RangeVal, constants.FlagNameRange, "p", 30, "Number of days to look-back") 81 | return cmd 82 | } 83 | 84 | func (opts *repositoriesOptions) run() error { 85 | repositories, err := utils.HandleRepositoryValues(opts.Repos, opts.FilePath) 86 | if err != nil { 87 | return err 88 | } 89 | var ( 90 | waitGroup = new(sync.WaitGroup) 91 | errorChan = make(chan error, len(repositories)) 92 | insightsChan = make(chan repositoryInsights, len(repositories)) 93 | doneChan = make(chan struct{}) 94 | insights = make(repositoryInsightsSlice, 0, len(repositories)) 95 | allErrors error 96 | ) 97 | go func() { 98 | for url := range repositories { 99 | repoURL := url 100 | waitGroup.Add(1) 101 | go func() { 102 | defer waitGroup.Done() 103 | allData, err := findAllRepositoryInsights(opts, repoURL) 104 | if err != nil { 105 | errorChan <- err 106 | return 107 | } 108 | if allData == nil { 109 | return 110 | } 111 | insightsChan <- *allData 112 | }() 113 | } 114 | waitGroup.Wait() 115 | close(doneChan) 116 | }() 117 | for { 118 | select { 119 | case err = <-errorChan: 120 | allErrors = errors.Join(allErrors, err) 121 | case data := <-insightsChan: 122 | insights = append(insights, data) 123 | case <-doneChan: 124 | if allErrors != nil { 125 | return allErrors 126 | } 127 | output, err := insights.BuildOutput(opts.Output) 128 | if err != nil { 129 | return err 130 | } 131 | fmt.Println(output) 132 | return nil 133 | } 134 | } 135 | } 136 | 137 | type repositoryInsights struct { 138 | RepoURL string `json:"repo_url" yaml:"repo_url"` 139 | RepoID int `json:"-" yaml:"-"` 140 | AllPullRequests int `json:"all_pull_requests" yaml:"all_pull_requests"` 141 | AcceptedPullRequests int `json:"accepted_pull_requests" yaml:"accepted_pull_requests"` 142 | SpamPullRequests int `json:"spam_pull_requests" yaml:"spam_pull_requests"` 143 | Contributors []string `json:"contributors" yaml:"contributors"` 144 | } 145 | 146 | type repositoryInsightsSlice []repositoryInsights 147 | 148 | func (ris repositoryInsightsSlice) BuildOutput(format string) (string, error) { 149 | switch format { 150 | case constants.OutputTable: 151 | return ris.OutputTable() 152 | case constants.OutputJSON: 153 | return utils.OutputJSON(ris) 154 | case constants.OutputYAML: 155 | return utils.OutputYAML(ris) 156 | default: 157 | return "", fmt.Errorf("unknown output format %s", format) 158 | } 159 | } 160 | 161 | func (ris repositoryInsightsSlice) OutputTable() (string, error) { 162 | tables := make([]string, 0, len(ris)) 163 | for i := range ris { 164 | rows := []bubblesTable.Row{ 165 | { 166 | "All pull requests", 167 | strconv.Itoa(ris[i].AllPullRequests), 168 | }, 169 | { 170 | "Accepted pull requests", 171 | strconv.Itoa(ris[i].AcceptedPullRequests), 172 | }, 173 | { 174 | "Spam pull requests", 175 | strconv.Itoa(ris[i].SpamPullRequests), 176 | }, 177 | { 178 | "Contributors", 179 | strconv.Itoa(len(ris[i].Contributors)), 180 | }, 181 | } 182 | columns := []bubblesTable.Column{ 183 | { 184 | Title: "Repository URL", 185 | Width: utils.GetMaxTableRowWidth(rows), 186 | }, 187 | { 188 | Title: ris[i].RepoURL, 189 | Width: len(ris[i].RepoURL), 190 | }, 191 | } 192 | tables = append(tables, utils.OutputTable(rows, columns)) 193 | } 194 | separator := fmt.Sprintf("\n%s\n", strings.Repeat("―", 3)) 195 | return strings.Join(tables, separator), nil 196 | } 197 | 198 | func findAllRepositoryInsights(opts *repositoriesOptions, repoURL string) (*repositoryInsights, error) { 199 | repo, err := findRepositoryByOwnerAndRepoName(opts.APIClient, repoURL) 200 | if err != nil { 201 | return nil, fmt.Errorf("could not get repository insights for repository %s: %w", repoURL, err) 202 | } 203 | if repo == nil { 204 | return nil, nil 205 | } 206 | repoInsights := &repositoryInsights{ 207 | RepoID: repo.ID, 208 | RepoURL: repo.SvnURL, 209 | } 210 | 211 | var ( 212 | waitGroup = new(sync.WaitGroup) 213 | errorChan = make(chan error, 4) 214 | ) 215 | 216 | waitGroup.Add(1) 217 | go func() { 218 | defer waitGroup.Done() 219 | response, err := getPullRequestInsights(opts.APIClient, repo.FullName, opts.RangeVal) 220 | if err != nil { 221 | errorChan <- err 222 | return 223 | } 224 | 225 | for _, bucket := range response { 226 | repoInsights.AllPullRequests += bucket.PrCount 227 | repoInsights.AcceptedPullRequests += bucket.AcceptedPrs 228 | repoInsights.SpamPullRequests += bucket.SpamPrs 229 | } 230 | }() 231 | 232 | waitGroup.Add(1) 233 | go func() { 234 | defer waitGroup.Done() 235 | response, err := searchAllPullRequestContributors(opts.APIClient, []string{repo.FullName}, opts.RangeVal) 236 | if err != nil { 237 | errorChan <- err 238 | return 239 | } 240 | var contributors []string 241 | for _, contributor := range response.Data { 242 | contributors = append(contributors, contributor.AuthorLogin) 243 | } 244 | repoInsights.Contributors = contributors 245 | }() 246 | waitGroup.Wait() 247 | close(errorChan) 248 | if len(errorChan) > 0 { 249 | var allErrors error 250 | for err = range errorChan { 251 | allErrors = errors.Join(allErrors, err) 252 | } 253 | return nil, allErrors 254 | } 255 | 256 | return repoInsights, nil 257 | } 258 | 259 | func getPullRequestInsights(apiClient *api.Client, repo string, rangeVal int) ([]histogram.PrHistogramData, error) { 260 | data, _, err := apiClient.HistogramService.PrsHistogram(repo, rangeVal) 261 | if err != nil { 262 | return nil, fmt.Errorf("error while calling 'PullRequestsServiceAPI.GetPullRequestInsights' with repository %s': %w", repo, err) 263 | } 264 | 265 | if len(data) == 0 { 266 | return nil, fmt.Errorf("could not find pull request insights for repository %s with interval %d", repo, rangeVal) 267 | } 268 | 269 | return data, nil 270 | } 271 | 272 | func searchAllPullRequestContributors(apiClient *api.Client, repos []string, rangeVal int) (*contributors.ContribResponse, error) { 273 | data, _, err := apiClient.ContributorService.SearchPullRequestContributors(repos, rangeVal) 274 | if err != nil { 275 | return nil, fmt.Errorf("error while calling 'ContributorService.SearchPullRequestContributors' with repository %v': %w", repos, err) 276 | } 277 | 278 | return data, nil 279 | } 280 | -------------------------------------------------------------------------------- /cmd/insights/user-contributions.go: -------------------------------------------------------------------------------- 1 | package insights 2 | 3 | import ( 4 | "bytes" 5 | "encoding/csv" 6 | "errors" 7 | "fmt" 8 | "sort" 9 | "strconv" 10 | "sync" 11 | 12 | bubblesTable "github.com/charmbracelet/bubbles/table" 13 | "github.com/spf13/cobra" 14 | 15 | "github.com/open-sauced/pizza-cli/v2/api" 16 | "github.com/open-sauced/pizza-cli/v2/pkg/constants" 17 | "github.com/open-sauced/pizza-cli/v2/pkg/utils" 18 | ) 19 | 20 | type userContributionsOptions struct { 21 | // APIClient is the http client for making calls to the open-sauced api 22 | APIClient *api.Client 23 | 24 | // Repos is the array of git repository urls 25 | Repos []string 26 | 27 | // Users is the list of usernames to filter for 28 | Users []string 29 | 30 | // usersMap is a fast access set of usernames built from the Users string slice 31 | usersMap map[string]struct{} 32 | 33 | // FilePath is the path to yaml file containing an array of git repository urls 34 | FilePath string 35 | 36 | // Period is the number of days, used for query filtering 37 | Period int32 38 | 39 | // Output is the formatting style for command output 40 | Output string 41 | 42 | // Sort is the column to be used to sort user contributions (total, commits, pr, none) 43 | Sort string 44 | } 45 | 46 | // NewUserContributionsCommand returns a new user-contributions command 47 | func NewUserContributionsCommand() *cobra.Command { 48 | opts := &userContributionsOptions{} 49 | 50 | cmd := &cobra.Command{ 51 | Use: "user-contributions url... [flags]", 52 | Short: "Gather insights on individual contributors for given repo URLs", 53 | Long: "Gather insights on individual contributors given a list of repository URLs", 54 | Args: func(cmd *cobra.Command, args []string) error { 55 | fileFlag := cmd.Flags().Lookup(constants.FlagNameFile) 56 | if !fileFlag.Changed && len(args) == 0 { 57 | return fmt.Errorf("must specify git repository url argument(s) or provide %s flag", fileFlag.Name) 58 | } 59 | opts.Repos = append(opts.Repos, args...) 60 | return nil 61 | }, 62 | RunE: func(cmd *cobra.Command, _ []string) error { 63 | endpointURL, _ := cmd.Flags().GetString(constants.FlagNameEndpoint) 64 | opts.APIClient = api.NewClient(endpointURL) 65 | output, _ := cmd.Flags().GetString(constants.FlagNameOutput) 66 | opts.Output = output 67 | return opts.run() 68 | }, 69 | } 70 | 71 | cmd.Flags().StringVarP(&opts.FilePath, constants.FlagNameFile, "f", "", "Path to yaml file containing an array of git repository urls") 72 | cmd.Flags().Int32VarP(&opts.Period, constants.FlagNameRange, "p", 30, "Number of days, used for query filtering") 73 | cmd.Flags().StringSliceVarP(&opts.Users, "users", "u", []string{}, "Inclusive comma separated list of GitHub usernames to filter for") 74 | cmd.Flags().StringVarP(&opts.Sort, "sort", "s", "none", "Sort user contributions by (total, commits, prs)") 75 | 76 | return cmd 77 | } 78 | 79 | func (opts *userContributionsOptions) run() error { 80 | repositories, err := utils.HandleRepositoryValues(opts.Repos, opts.FilePath) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | opts.usersMap = make(map[string]struct{}) 86 | for _, username := range opts.Users { 87 | // For fast access to list of users to filter out, uses an empty struct 88 | opts.usersMap[username] = struct{}{} 89 | } 90 | 91 | var ( 92 | waitGroup = new(sync.WaitGroup) 93 | errorChan = make(chan error, len(repositories)) 94 | insightsChan = make(chan *userContributionsInsightGroup, len(repositories)) 95 | doneChan = make(chan struct{}) 96 | insights = make([]*userContributionsInsightGroup, 0, len(repositories)) 97 | allErrors error 98 | ) 99 | 100 | go func() { 101 | for url := range repositories { 102 | repoURL := url 103 | waitGroup.Add(1) 104 | 105 | go func() { 106 | defer waitGroup.Done() 107 | 108 | data, err := findAllUserContributionsInsights(opts, repoURL) 109 | if err != nil { 110 | errorChan <- err 111 | return 112 | } 113 | 114 | if data == nil { 115 | return 116 | } 117 | 118 | insightsChan <- data 119 | }() 120 | } 121 | 122 | waitGroup.Wait() 123 | close(doneChan) 124 | }() 125 | 126 | for { 127 | select { 128 | case err = <-errorChan: 129 | allErrors = errors.Join(allErrors, err) 130 | case data := <-insightsChan: 131 | insights = append(insights, data) 132 | case <-doneChan: 133 | if allErrors != nil { 134 | return allErrors 135 | } 136 | 137 | if opts.Sort != "none" { 138 | sortUserContributions(insights, opts.Sort) 139 | } 140 | 141 | for _, insight := range insights { 142 | output, err := insight.BuildOutput(opts.Output) 143 | if err != nil { 144 | return err 145 | } 146 | 147 | fmt.Println(output) 148 | } 149 | 150 | return nil 151 | } 152 | } 153 | } 154 | 155 | type userContributionsInsights struct { 156 | Login string `json:"login" yaml:"login"` 157 | Commits int `json:"commits" yaml:"commits"` 158 | PrsCreated int `json:"prs_created" yaml:"prs_created"` 159 | TotalContributions int `json:"total_contributions" yaml:"total_contributions"` 160 | } 161 | 162 | type userContributionsInsightGroup struct { 163 | RepoURL string `json:"repo_url" yaml:"repo_url"` 164 | Insights []userContributionsInsights 165 | } 166 | 167 | func (ucig userContributionsInsightGroup) BuildOutput(format string) (string, error) { 168 | switch format { 169 | case constants.OutputTable: 170 | return ucig.OutputTable() 171 | case constants.OutputJSON: 172 | return utils.OutputJSON(ucig) 173 | case constants.OutputYAML: 174 | return utils.OutputYAML(ucig) 175 | case constants.OuputCSV: 176 | return ucig.OutputCSV() 177 | default: 178 | return "", fmt.Errorf("unknown output format %s", format) 179 | } 180 | } 181 | 182 | func (ucig userContributionsInsightGroup) OutputCSV() (string, error) { 183 | if len(ucig.Insights) == 0 { 184 | return "", errors.New("repository is either non-existent or has not been indexed yet") 185 | } 186 | 187 | b := new(bytes.Buffer) 188 | writer := csv.NewWriter(b) 189 | 190 | // write headers 191 | err := writer.WriteAll([][]string{ 192 | { 193 | ucig.RepoURL, 194 | }, 195 | { 196 | "User", 197 | "Total", 198 | "Commits", 199 | "PRs Created", 200 | }, 201 | }) 202 | if err != nil { 203 | return "", err 204 | } 205 | 206 | // write records 207 | for _, uci := range ucig.Insights { 208 | err := writer.WriteAll([][]string{ 209 | { 210 | uci.Login, 211 | strconv.Itoa(uci.Commits + uci.PrsCreated), 212 | strconv.Itoa(uci.Commits), 213 | strconv.Itoa(uci.PrsCreated), 214 | }, 215 | }) 216 | 217 | if err != nil { 218 | return "", err 219 | } 220 | } 221 | 222 | return b.String(), nil 223 | } 224 | 225 | func (ucig userContributionsInsightGroup) OutputTable() (string, error) { 226 | rows := []bubblesTable.Row{} 227 | 228 | for _, uci := range ucig.Insights { 229 | rows = append(rows, bubblesTable.Row{ 230 | uci.Login, 231 | strconv.Itoa(uci.TotalContributions), 232 | strconv.Itoa(uci.Commits), 233 | strconv.Itoa(uci.PrsCreated), 234 | }) 235 | } 236 | 237 | columns := []bubblesTable.Column{ 238 | { 239 | Title: "User", 240 | Width: utils.GetMaxTableRowWidth(rows), 241 | }, 242 | { 243 | Title: "Total", 244 | Width: 10, 245 | }, 246 | { 247 | Title: "Commits", 248 | Width: 10, 249 | }, 250 | { 251 | Title: "PRs Created", 252 | Width: 15, 253 | }, 254 | } 255 | 256 | return fmt.Sprintf("%s\n%s\n", ucig.RepoURL, utils.OutputTable(rows, columns)), nil 257 | } 258 | 259 | func findAllUserContributionsInsights(opts *userContributionsOptions, repoURL string) (*userContributionsInsightGroup, error) { 260 | owner, name, err := utils.GetOwnerAndRepoFromURL(repoURL) 261 | if err != nil { 262 | return nil, err 263 | } 264 | 265 | repoUserContributionsInsightGroup := &userContributionsInsightGroup{ 266 | RepoURL: repoURL, 267 | } 268 | 269 | dataPoints, _, err := opts.APIClient.RepositoryService.FindContributorsByOwnerAndRepo(owner, name, 30) 270 | 271 | if err != nil { 272 | return nil, fmt.Errorf("error while calling API RepositoryService.FindContributorsByOwnerAndRepo with repository %s/%s': %w", owner, name, err) 273 | } 274 | 275 | for _, data := range dataPoints.Data { 276 | _, ok := opts.usersMap[data.Login] 277 | if len(opts.usersMap) == 0 || ok { 278 | repoUserContributionsInsightGroup.Insights = append(repoUserContributionsInsightGroup.Insights, userContributionsInsights{ 279 | Login: data.Login, 280 | Commits: data.Commits, 281 | PrsCreated: data.PRsCreated, 282 | TotalContributions: data.Commits + data.PRsCreated, 283 | }) 284 | } 285 | } 286 | 287 | return repoUserContributionsInsightGroup, nil 288 | } 289 | 290 | func sortUserContributions(ucig []*userContributionsInsightGroup, sortBy string) { 291 | for _, group := range ucig { 292 | if group != nil { 293 | sort.SliceStable(group.Insights, func(i, j int) bool { 294 | switch sortBy { 295 | case "total": 296 | return group.Insights[i].TotalContributions > group.Insights[j].TotalContributions 297 | case "prs": 298 | return group.Insights[i].PrsCreated > group.Insights[j].PrsCreated 299 | case "commits": 300 | return group.Insights[i].Commits > group.Insights[j].Commits 301 | } 302 | return group.Insights[i].Login < group.Insights[j].Login 303 | }) 304 | } 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /cmd/insights/utils.go: -------------------------------------------------------------------------------- 1 | package insights 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/open-sauced/pizza-cli/v2/api" 8 | "github.com/open-sauced/pizza-cli/v2/api/services/repository" 9 | "github.com/open-sauced/pizza-cli/v2/pkg/utils" 10 | ) 11 | 12 | // findRepositoryByOwnerAndRepoName returns an API client Db Repo 13 | // based on the given repository URL 14 | func findRepositoryByOwnerAndRepoName(apiClient *api.Client, repoURL string) (*repository.DbRepository, error) { 15 | owner, repoName, err := utils.GetOwnerAndRepoFromURL(repoURL) 16 | if err != nil { 17 | return nil, fmt.Errorf("could not extract owner and repo from url: %w", err) 18 | } 19 | 20 | repo, response, err := apiClient.RepositoryService.FindOneByOwnerAndRepo(owner, repoName) 21 | if err != nil { 22 | if response != nil && response.StatusCode == http.StatusNotFound { 23 | return nil, fmt.Errorf("repository %s is either non-existent, private, or has not been indexed yet", repoURL) 24 | } 25 | return nil, fmt.Errorf("error while calling 'RepositoryServiceAPI.FindOneByOwnerAndRepo' with owner %q and repo %q: %w", owner, repoName, err) 26 | } 27 | 28 | return repo, nil 29 | } 30 | -------------------------------------------------------------------------------- /cmd/offboard/offboard.go: -------------------------------------------------------------------------------- 1 | package offboard 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "slices" 7 | "strings" 8 | 9 | "github.com/spf13/cobra" 10 | 11 | "github.com/open-sauced/pizza-cli/v2/pkg/config" 12 | "github.com/open-sauced/pizza-cli/v2/pkg/constants" 13 | "github.com/open-sauced/pizza-cli/v2/pkg/utils" 14 | ) 15 | 16 | type Options struct { 17 | offboardingUsers []string 18 | 19 | // config file path 20 | configPath string 21 | 22 | // repository path 23 | path string 24 | 25 | // from global config 26 | ttyDisabled bool 27 | 28 | // telemetry for capturing CLI events via PostHog 29 | telemetry *utils.PosthogCliClient 30 | } 31 | 32 | const offboardLongDesc string = `CAUTION: Experimental Command. Removes users from the \".sauced.yaml\" config and \"CODEOWNERS\" files. 33 | Requires the users' name OR email.` 34 | 35 | func NewConfigCommand() *cobra.Command { 36 | opts := &Options{} 37 | cmd := &cobra.Command{ 38 | Use: "offboard [flags]", 39 | Short: "CAUTION: Experimental Command. Removes users from the \".sauced.yaml\" config and \"CODEOWNERS\" files.", 40 | Long: offboardLongDesc, 41 | Args: func(_ *cobra.Command, args []string) error { 42 | if len(args) == 0 { 43 | return errors.New("you must provide at least one argument: the offboarding user's email/username") 44 | } 45 | 46 | opts.offboardingUsers = args 47 | 48 | return nil 49 | }, 50 | RunE: func(cmd *cobra.Command, _ []string) error { 51 | opts.ttyDisabled, _ = cmd.Flags().GetBool("tty-disable") 52 | opts.configPath, _ = cmd.Flags().GetString("config") 53 | disableTelem, _ := cmd.Flags().GetBool(constants.FlagNameTelemetry) 54 | 55 | opts.telemetry = utils.NewPosthogCliClient(!disableTelem) 56 | 57 | opts.path, _ = cmd.Flags().GetString("path") 58 | err := run(opts) 59 | _ = opts.telemetry.Done() 60 | 61 | return err 62 | }, 63 | } 64 | 65 | cmd.PersistentFlags().StringP("path", "p", "", "the path to the repository (required)") 66 | if err := cmd.MarkPersistentFlagRequired("path"); err != nil { 67 | fmt.Printf("error MarkPersistentFlagRequired: %v", err) 68 | } 69 | return cmd 70 | } 71 | 72 | func run(opts *Options) error { 73 | var spec *config.Spec 74 | var err error 75 | if len(opts.configPath) != 0 { 76 | spec, _, err = config.LoadConfig(opts.configPath) 77 | } else { 78 | var configPath string 79 | if strings.Compare(string(opts.path[len(opts.path)-1]), "/") == 0 { 80 | configPath = opts.path + ".sauced.yaml" 81 | } else { 82 | configPath = opts.path + "/.sauced.yaml" 83 | } 84 | spec, _, err = config.LoadConfig(configPath) 85 | } 86 | 87 | if err != nil { 88 | _ = opts.telemetry.CaptureFailedOffboard() 89 | return fmt.Errorf("error loading config: %v", err) 90 | } 91 | 92 | var offboardingNames []string 93 | attributions := spec.Attributions 94 | for _, user := range opts.offboardingUsers { 95 | added := false 96 | 97 | // deletes if the user is a name (key) 98 | delete(attributions, user) 99 | 100 | // delete if the user is an email (value) 101 | for k, v := range attributions { 102 | if slices.Contains(v, user) { 103 | offboardingNames = append(offboardingNames, k) 104 | delete(attributions, k) 105 | added = true 106 | } 107 | } 108 | 109 | if !added { 110 | offboardingNames = append(offboardingNames, user) 111 | } 112 | } 113 | 114 | if len(opts.configPath) != 0 { 115 | err = generateConfigFile(opts.configPath, attributions) 116 | } else { 117 | var configPath string 118 | if strings.Compare(string(opts.path[len(opts.path)-1]), "/") == 0 { 119 | configPath = opts.path + ".sauced.yaml" 120 | } else { 121 | configPath = opts.path + "/.sauced.yaml" 122 | } 123 | err = generateConfigFile(configPath, attributions) 124 | } 125 | 126 | if err != nil { 127 | _ = opts.telemetry.CaptureFailedOffboard() 128 | return fmt.Errorf("error generating config file: %v", err) 129 | } 130 | 131 | err = generateOwnersFile(opts.path, offboardingNames) 132 | if err != nil { 133 | _ = opts.telemetry.CaptureFailedOffboard() 134 | return fmt.Errorf("error generating owners file: %v", err) 135 | } 136 | 137 | _ = opts.telemetry.CaptureOffboard() 138 | return nil 139 | } 140 | -------------------------------------------------------------------------------- /cmd/offboard/output.go: -------------------------------------------------------------------------------- 1 | package offboard 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/open-sauced/pizza-cli/v2/pkg/config" 11 | "github.com/open-sauced/pizza-cli/v2/pkg/utils" 12 | ) 13 | 14 | func generateConfigFile(outputPath string, attributionMap map[string][]string) error { 15 | file, err := os.Create(outputPath) 16 | if err != nil { 17 | return fmt.Errorf("error creating %s file: %w", outputPath, err) 18 | } 19 | defer file.Close() 20 | 21 | var config config.Spec 22 | config.Attributions = attributionMap 23 | 24 | // for pretty print test 25 | yaml, err := utils.OutputYAML(config) 26 | 27 | if err != nil { 28 | return fmt.Errorf("failed to turn into YAML: %w", err) 29 | } 30 | 31 | _, err = file.WriteString(yaml) 32 | 33 | if err != nil { 34 | return fmt.Errorf("failed to turn into YAML: %w", err) 35 | } 36 | 37 | return nil 38 | } 39 | 40 | func generateOwnersFile(path string, offboardingUsers []string) error { 41 | outputType := "/CODEOWNERS" 42 | var owners []byte 43 | var err error 44 | 45 | var ownersPath string 46 | 47 | if _, err = os.Stat(filepath.Join(path, "/CODEOWNERS")); !errors.Is(err, os.ErrNotExist) { 48 | outputType = "CODEOWNERS" 49 | ownersPath = filepath.Join(path, "/CODEOWNERS") 50 | owners, err = os.ReadFile(ownersPath) 51 | } else if _, err = os.Stat(filepath.Join(path, "OWNERS")); !errors.Is(err, os.ErrNotExist) { 52 | outputType = "OWNERS" 53 | ownersPath = filepath.Join(path, "/OWNERS") 54 | owners, err = os.ReadFile(ownersPath) 55 | } 56 | 57 | if err != nil { 58 | fmt.Printf("will create a new %s file in the path %s", outputType, path) 59 | } 60 | 61 | lines := strings.Split(string(owners), "\n") 62 | var newLines []string 63 | for _, line := range lines { 64 | newLine := line 65 | for _, name := range offboardingUsers { 66 | result, _, found := strings.Cut(newLine, "@"+name) 67 | if found { 68 | newLine = result 69 | } 70 | } 71 | newLines = append(newLines, newLine) 72 | } 73 | 74 | output := strings.Join(newLines, "\n") 75 | file, err := os.Create(ownersPath) 76 | if err != nil { 77 | return fmt.Errorf("error creating %s file: %w", outputType, err) 78 | } 79 | defer file.Close() 80 | 81 | _, err = file.WriteString(output) 82 | if err != nil { 83 | return fmt.Errorf("failed writing file %s: %w", path+outputType, err) 84 | } 85 | 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /cmd/root/root.go: -------------------------------------------------------------------------------- 1 | // Package root initiates and bootstraps the pizza CLI root Cobra command 2 | package root 3 | 4 | import ( 5 | "fmt" 6 | 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/open-sauced/pizza-cli/v2/cmd/auth" 10 | "github.com/open-sauced/pizza-cli/v2/cmd/docs" 11 | "github.com/open-sauced/pizza-cli/v2/cmd/generate" 12 | "github.com/open-sauced/pizza-cli/v2/cmd/insights" 13 | "github.com/open-sauced/pizza-cli/v2/cmd/offboard" 14 | "github.com/open-sauced/pizza-cli/v2/cmd/version" 15 | "github.com/open-sauced/pizza-cli/v2/pkg/constants" 16 | ) 17 | 18 | // NewRootCommand bootstraps a new root cobra command for the pizza CLI 19 | func NewRootCommand() (*cobra.Command, error) { 20 | cmd := &cobra.Command{ 21 | Use: "pizza [flags]", 22 | Short: "OpenSauced CLI", 23 | Long: "A command line utility for insights, metrics, and generating CODEOWNERS documentation for your open source projects", 24 | RunE: run, 25 | Args: func(cmd *cobra.Command, _ []string) error { 26 | betaFlag := cmd.Flags().Lookup(constants.FlagNameBeta) 27 | if betaFlag.Changed { 28 | err := cmd.Flags().Lookup(constants.FlagNameEndpoint).Value.Set(constants.EndpointBeta) 29 | if err != nil { 30 | return err 31 | } 32 | } 33 | return nil 34 | }, 35 | } 36 | 37 | cmd.PersistentFlags().StringP(constants.FlagNameEndpoint, "e", constants.EndpointProd, "The API endpoint to send requests to") 38 | cmd.PersistentFlags().Bool(constants.FlagNameBeta, false, fmt.Sprintf("Shorthand for using the beta OpenSauced API endpoint (\"%s\"). Supersedes the '--%s' flag", constants.EndpointBeta, constants.FlagNameEndpoint)) 39 | cmd.PersistentFlags().Bool(constants.FlagNameTelemetry, false, "Disable sending telemetry data to OpenSauced") 40 | cmd.PersistentFlags().StringP("config", "c", "", "The codeowners config") 41 | cmd.PersistentFlags().StringP("log-level", "l", "info", "The logging level. Options: error, warn, info, debug") 42 | cmd.PersistentFlags().Bool("tty-disable", false, "Disable log stylization. Suitable for CI/CD and automation") 43 | 44 | cmd.AddCommand(auth.NewLoginCommand()) 45 | cmd.AddCommand(generate.NewGenerateCommand()) 46 | cmd.AddCommand(insights.NewInsightsCommand()) 47 | cmd.AddCommand(version.NewVersionCommand()) 48 | cmd.AddCommand(offboard.NewConfigCommand()) 49 | 50 | // The docs command is hidden as it's only used by the pizza-cli maintainers 51 | docsCmd := docs.NewDocsCommand() 52 | docsCmd.Hidden = true 53 | cmd.AddCommand(docsCmd) 54 | 55 | err := cmd.PersistentFlags().MarkHidden(constants.FlagNameEndpoint) 56 | if err != nil { 57 | return nil, fmt.Errorf("error marking %s as hidden: %w", constants.FlagNameEndpoint, err) 58 | } 59 | 60 | err = cmd.PersistentFlags().MarkHidden(constants.FlagNameBeta) 61 | if err != nil { 62 | return nil, fmt.Errorf("error marking %s as hidden: %w", constants.FlagNameBeta, err) 63 | } 64 | 65 | return cmd, nil 66 | } 67 | 68 | func run(cmd *cobra.Command, _ []string) error { 69 | return cmd.Help() 70 | } 71 | -------------------------------------------------------------------------------- /cmd/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/open-sauced/pizza-cli/v2/pkg/utils" 9 | ) 10 | 11 | func NewVersionCommand() *cobra.Command { 12 | return &cobra.Command{ 13 | Use: "version", 14 | Short: "Displays the build version of the CLI", 15 | Run: func(_ *cobra.Command, _ []string) { 16 | fmt.Printf("Version: %s\nSha: %s\nBuilt at: %s\n", utils.Version, utils.Sha, utils.Datetime) 17 | }, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /docs/pizza.md: -------------------------------------------------------------------------------- 1 | ## pizza 2 | 3 | OpenSauced CLI 4 | 5 | ### Synopsis 6 | 7 | A command line utility for insights, metrics, and generating CODEOWNERS documentation for your open source projects 8 | 9 | ``` 10 | pizza [flags] 11 | ``` 12 | 13 | ### Options 14 | 15 | ``` 16 | -c, --config string The codeowners config 17 | --disable-telemetry Disable sending telemetry data to OpenSauced 18 | -h, --help help for pizza 19 | -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") 20 | --tty-disable Disable log stylization. Suitable for CI/CD and automation 21 | ``` 22 | 23 | ### SEE ALSO 24 | 25 | * [pizza completion](pizza_completion.md) - Generate the autocompletion script for the specified shell 26 | * [pizza generate](pizza_generate.md) - Generates documentation and insights from your codebase 27 | * [pizza insights](pizza_insights.md) - Gather insights about git contributors, repositories, users and pull requests 28 | * [pizza login](pizza_login.md) - Log into the CLI via GitHub 29 | * [pizza offboard](pizza_offboard.md) - CAUTION: Experimental Command. Removes users from the ".sauced.yaml" config and "CODEOWNERS" files. 30 | * [pizza version](pizza_version.md) - Displays the build version of the CLI 31 | 32 | -------------------------------------------------------------------------------- /docs/pizza_completion.md: -------------------------------------------------------------------------------- 1 | ## pizza completion 2 | 3 | Generate the autocompletion script for the specified shell 4 | 5 | ### Synopsis 6 | 7 | Generate the autocompletion script for pizza for the specified shell. 8 | See each sub-command's help for details on how to use the generated script. 9 | 10 | 11 | ### Options 12 | 13 | ``` 14 | -h, --help help for completion 15 | ``` 16 | 17 | ### Options inherited from parent commands 18 | 19 | ``` 20 | -c, --config string The codeowners config 21 | --disable-telemetry Disable sending telemetry data to OpenSauced 22 | -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") 23 | --tty-disable Disable log stylization. Suitable for CI/CD and automation 24 | ``` 25 | 26 | ### SEE ALSO 27 | 28 | * [pizza](pizza.md) - OpenSauced CLI 29 | * [pizza completion bash](pizza_completion_bash.md) - Generate the autocompletion script for bash 30 | * [pizza completion fish](pizza_completion_fish.md) - Generate the autocompletion script for fish 31 | * [pizza completion powershell](pizza_completion_powershell.md) - Generate the autocompletion script for powershell 32 | * [pizza completion zsh](pizza_completion_zsh.md) - Generate the autocompletion script for zsh 33 | 34 | -------------------------------------------------------------------------------- /docs/pizza_completion_bash.md: -------------------------------------------------------------------------------- 1 | ## pizza completion bash 2 | 3 | Generate the autocompletion script for bash 4 | 5 | ### Synopsis 6 | 7 | Generate the autocompletion script for the bash shell. 8 | 9 | This script depends on the 'bash-completion' package. 10 | If it is not installed already, you can install it via your OS's package manager. 11 | 12 | To load completions in your current shell session: 13 | 14 | source <(pizza completion bash) 15 | 16 | To load completions for every new session, execute once: 17 | 18 | #### Linux: 19 | 20 | pizza completion bash > /etc/bash_completion.d/pizza 21 | 22 | #### macOS: 23 | 24 | pizza completion bash > $(brew --prefix)/etc/bash_completion.d/pizza 25 | 26 | You will need to start a new shell for this setup to take effect. 27 | 28 | 29 | ``` 30 | pizza completion bash 31 | ``` 32 | 33 | ### Options 34 | 35 | ``` 36 | -h, --help help for bash 37 | --no-descriptions disable completion descriptions 38 | ``` 39 | 40 | ### Options inherited from parent commands 41 | 42 | ``` 43 | -c, --config string The codeowners config 44 | --disable-telemetry Disable sending telemetry data to OpenSauced 45 | -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") 46 | --tty-disable Disable log stylization. Suitable for CI/CD and automation 47 | ``` 48 | 49 | ### SEE ALSO 50 | 51 | * [pizza completion](pizza_completion.md) - Generate the autocompletion script for the specified shell 52 | 53 | -------------------------------------------------------------------------------- /docs/pizza_completion_fish.md: -------------------------------------------------------------------------------- 1 | ## pizza completion fish 2 | 3 | Generate the autocompletion script for fish 4 | 5 | ### Synopsis 6 | 7 | Generate the autocompletion script for the fish shell. 8 | 9 | To load completions in your current shell session: 10 | 11 | pizza completion fish | source 12 | 13 | To load completions for every new session, execute once: 14 | 15 | pizza completion fish > ~/.config/fish/completions/pizza.fish 16 | 17 | You will need to start a new shell for this setup to take effect. 18 | 19 | 20 | ``` 21 | pizza completion fish [flags] 22 | ``` 23 | 24 | ### Options 25 | 26 | ``` 27 | -h, --help help for fish 28 | --no-descriptions disable completion descriptions 29 | ``` 30 | 31 | ### Options inherited from parent commands 32 | 33 | ``` 34 | -c, --config string The codeowners config 35 | --disable-telemetry Disable sending telemetry data to OpenSauced 36 | -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") 37 | --tty-disable Disable log stylization. Suitable for CI/CD and automation 38 | ``` 39 | 40 | ### SEE ALSO 41 | 42 | * [pizza completion](pizza_completion.md) - Generate the autocompletion script for the specified shell 43 | 44 | -------------------------------------------------------------------------------- /docs/pizza_completion_powershell.md: -------------------------------------------------------------------------------- 1 | ## pizza completion powershell 2 | 3 | Generate the autocompletion script for powershell 4 | 5 | ### Synopsis 6 | 7 | Generate the autocompletion script for powershell. 8 | 9 | To load completions in your current shell session: 10 | 11 | pizza completion powershell | Out-String | Invoke-Expression 12 | 13 | To load completions for every new session, add the output of the above command 14 | to your powershell profile. 15 | 16 | 17 | ``` 18 | pizza completion powershell [flags] 19 | ``` 20 | 21 | ### Options 22 | 23 | ``` 24 | -h, --help help for powershell 25 | --no-descriptions disable completion descriptions 26 | ``` 27 | 28 | ### Options inherited from parent commands 29 | 30 | ``` 31 | -c, --config string The codeowners config 32 | --disable-telemetry Disable sending telemetry data to OpenSauced 33 | -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") 34 | --tty-disable Disable log stylization. Suitable for CI/CD and automation 35 | ``` 36 | 37 | ### SEE ALSO 38 | 39 | * [pizza completion](pizza_completion.md) - Generate the autocompletion script for the specified shell 40 | 41 | -------------------------------------------------------------------------------- /docs/pizza_completion_zsh.md: -------------------------------------------------------------------------------- 1 | ## pizza completion zsh 2 | 3 | Generate the autocompletion script for zsh 4 | 5 | ### Synopsis 6 | 7 | Generate the autocompletion script for the zsh shell. 8 | 9 | If shell completion is not already enabled in your environment you will need 10 | to enable it. You can execute the following once: 11 | 12 | echo "autoload -U compinit; compinit" >> ~/.zshrc 13 | 14 | To load completions in your current shell session: 15 | 16 | source <(pizza completion zsh) 17 | 18 | To load completions for every new session, execute once: 19 | 20 | #### Linux: 21 | 22 | pizza completion zsh > "${fpath[1]}/_pizza" 23 | 24 | #### macOS: 25 | 26 | pizza completion zsh > $(brew --prefix)/share/zsh/site-functions/_pizza 27 | 28 | You will need to start a new shell for this setup to take effect. 29 | 30 | 31 | ``` 32 | pizza completion zsh [flags] 33 | ``` 34 | 35 | ### Options 36 | 37 | ``` 38 | -h, --help help for zsh 39 | --no-descriptions disable completion descriptions 40 | ``` 41 | 42 | ### Options inherited from parent commands 43 | 44 | ``` 45 | -c, --config string The codeowners config 46 | --disable-telemetry Disable sending telemetry data to OpenSauced 47 | -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") 48 | --tty-disable Disable log stylization. Suitable for CI/CD and automation 49 | ``` 50 | 51 | ### SEE ALSO 52 | 53 | * [pizza completion](pizza_completion.md) - Generate the autocompletion script for the specified shell 54 | 55 | -------------------------------------------------------------------------------- /docs/pizza_generate.md: -------------------------------------------------------------------------------- 1 | ## pizza generate 2 | 3 | Generates documentation and insights from your codebase 4 | 5 | ### Synopsis 6 | 7 | The 'generate' command provides tools to automate the creation of important project documentation and derive insights from your codebase. 8 | 9 | ``` 10 | pizza generate [subcommand] [flags] 11 | ``` 12 | 13 | ### Options 14 | 15 | ``` 16 | -h, --help help for generate 17 | ``` 18 | 19 | ### Options inherited from parent commands 20 | 21 | ``` 22 | -c, --config string The codeowners config 23 | --disable-telemetry Disable sending telemetry data to OpenSauced 24 | -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") 25 | --tty-disable Disable log stylization. Suitable for CI/CD and automation 26 | ``` 27 | 28 | ### SEE ALSO 29 | 30 | * [pizza](pizza.md) - OpenSauced CLI 31 | * [pizza generate codeowners](pizza_generate_codeowners.md) - Generate a CODEOWNERS file for a GitHub repository using a "~/.sauced.yaml" config 32 | * [pizza generate config](pizza_generate_config.md) - Generates a ".sauced.yaml" config based on the current repository 33 | * [pizza generate insight](pizza_generate_insight.md) - Generate an OpenSauced Contributor Insight based on GitHub logins in a CODEOWNERS file 34 | 35 | -------------------------------------------------------------------------------- /docs/pizza_generate_codeowners.md: -------------------------------------------------------------------------------- 1 | ## pizza generate codeowners 2 | 3 | Generate a CODEOWNERS file for a GitHub repository using a "~/.sauced.yaml" config 4 | 5 | ### Synopsis 6 | 7 | Generates a CODEOWNERS file for a given git repository. The generated file specifies up to 3 owners for EVERY file in the git tree based on the number of lines touched in that specific file over the specified range of time. 8 | 9 | Configuration: 10 | The command requires a .sauced.yaml file for accurate attribution. This file maps 11 | commit email addresses to GitHub usernames. The command looks for this file in two locations: 12 | 13 | 1. In the root of the specified repository path 14 | 2. In the user's home directory (~/.sauced.yaml) if not found in the repository 15 | 16 | If you run the command on a specific path, it will first look for .sauced.yaml in that 17 | path. If not found, it will fall back to ~/.sauced.yaml. 18 | 19 | ``` 20 | pizza generate codeowners path/to/repo [flags] 21 | ``` 22 | 23 | ### Examples 24 | 25 | ``` 26 | 27 | # Generate CODEOWNERS file for the current directory 28 | pizza generate codeowners . 29 | 30 | # Generate CODEOWNERS file for a specific repository 31 | pizza generate codeowners /path/to/your/repo 32 | 33 | # Generate CODEOWNERS file analyzing the last 180 days 34 | pizza generate codeowners . --range 180 35 | 36 | # Generate an OWNERS style file instead of CODEOWNERS 37 | pizza generate codeowners . --owners-style-file 38 | 39 | # Specify a custom location for the .sauced.yaml file 40 | pizza generate codeowners . --config /path/to/.sauced.yaml 41 | 42 | # Specify a custom output location for the CODEOWNERS file 43 | pizza generate codeowners . --output-path /path/to/directory 44 | 45 | ``` 46 | 47 | ### Options 48 | 49 | ``` 50 | -h, --help help for codeowners 51 | -o, --output-path string Directory to create the output file. 52 | --owners-style-file Generate an agnostic OWNERS style file instead of CODEOWNERS. 53 | -r, --range int The number of days to analyze commit history (default 90) (default 90) 54 | ``` 55 | 56 | ### Options inherited from parent commands 57 | 58 | ``` 59 | -c, --config string The codeowners config 60 | --disable-telemetry Disable sending telemetry data to OpenSauced 61 | -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") 62 | --tty-disable Disable log stylization. Suitable for CI/CD and automation 63 | ``` 64 | 65 | ### SEE ALSO 66 | 67 | * [pizza generate](pizza_generate.md) - Generates documentation and insights from your codebase 68 | 69 | -------------------------------------------------------------------------------- /docs/pizza_generate_config.md: -------------------------------------------------------------------------------- 1 | ## pizza generate config 2 | 3 | Generates a ".sauced.yaml" config based on the current repository 4 | 5 | ### Synopsis 6 | 7 | Generates a ".sauced.yaml" configuration file for use with the Pizza CLI's codeowners command. 8 | 9 | This command analyzes the git history of the current repository to create a mapping 10 | of email addresses to GitHub usernames. 11 | 12 | ``` 13 | pizza generate config path/to/repo [flags] 14 | ``` 15 | 16 | ### Options 17 | 18 | ``` 19 | -h, --help help for config 20 | -i, --interactive Whether to be interactive 21 | -o, --output-path .sauced.yaml Directory to create the .sauced.yaml file. (default "./") 22 | -r, --range int The number of days to analyze commit history (default 90) (default 90) 23 | ``` 24 | 25 | ### Options inherited from parent commands 26 | 27 | ``` 28 | -c, --config string The codeowners config 29 | --disable-telemetry Disable sending telemetry data to OpenSauced 30 | -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") 31 | --tty-disable Disable log stylization. Suitable for CI/CD and automation 32 | ``` 33 | 34 | ### SEE ALSO 35 | 36 | * [pizza generate](pizza_generate.md) - Generates documentation and insights from your codebase 37 | 38 | -------------------------------------------------------------------------------- /docs/pizza_generate_insight.md: -------------------------------------------------------------------------------- 1 | ## pizza generate insight 2 | 3 | Generate an OpenSauced Contributor Insight based on GitHub logins in a CODEOWNERS file 4 | 5 | ### Synopsis 6 | 7 | Generate an OpenSauced Contributor Insight based on GitHub logins in a CODEOWNERS file 8 | to get metrics and insights on those users. 9 | 10 | The provided path must be a local git repo with a valid CODEOWNERS file and GitHub "@login" 11 | for each codeowner. 12 | 13 | After logging in, the generated Contributor Insight on OpenSauced will have insights on 14 | active contributors, contributon velocity, and more. 15 | 16 | ``` 17 | pizza generate insight path/to/repo/with/CODEOWNERS/file [flags] 18 | ``` 19 | 20 | ### Examples 21 | 22 | ``` 23 | # Use CODEOWNERS file in explicit directory 24 | $ pizza generate insight /path/to/repo 25 | 26 | # Use CODEOWNERS file in local directory 27 | $ pizza generate insight . 28 | ``` 29 | 30 | ### Options 31 | 32 | ``` 33 | -h, --help help for insight 34 | ``` 35 | 36 | ### Options inherited from parent commands 37 | 38 | ``` 39 | -c, --config string The codeowners config 40 | --disable-telemetry Disable sending telemetry data to OpenSauced 41 | -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") 42 | --tty-disable Disable log stylization. Suitable for CI/CD and automation 43 | ``` 44 | 45 | ### SEE ALSO 46 | 47 | * [pizza generate](pizza_generate.md) - Generates documentation and insights from your codebase 48 | 49 | -------------------------------------------------------------------------------- /docs/pizza_insights.md: -------------------------------------------------------------------------------- 1 | ## pizza insights 2 | 3 | Gather insights about git contributors, repositories, users and pull requests 4 | 5 | ### Synopsis 6 | 7 | Gather insights about git contributors, repositories, user and pull requests and display the results 8 | 9 | ``` 10 | pizza insights [flags] 11 | ``` 12 | 13 | ### Options 14 | 15 | ``` 16 | -h, --help help for insights 17 | -o, --output string The formatting for command output. One of: (table, yaml, csv, json) (default "table") 18 | ``` 19 | 20 | ### Options inherited from parent commands 21 | 22 | ``` 23 | -c, --config string The codeowners config 24 | --disable-telemetry Disable sending telemetry data to OpenSauced 25 | -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") 26 | --tty-disable Disable log stylization. Suitable for CI/CD and automation 27 | ``` 28 | 29 | ### SEE ALSO 30 | 31 | * [pizza](pizza.md) - OpenSauced CLI 32 | * [pizza insights contributors](pizza_insights_contributors.md) - Gather insights about contributors of indexed git repositories 33 | * [pizza insights repositories](pizza_insights_repositories.md) - Gather insights about indexed git repositories 34 | * [pizza insights user-contributions](pizza_insights_user-contributions.md) - Gather insights on individual contributors for given repo URLs 35 | 36 | -------------------------------------------------------------------------------- /docs/pizza_insights_contributors.md: -------------------------------------------------------------------------------- 1 | ## pizza insights contributors 2 | 3 | Gather insights about contributors of indexed git repositories 4 | 5 | ### Synopsis 6 | 7 | Gather insights about contributors of indexed git repositories. This command will show new, recent, alumni, repeat contributors for each git repository 8 | 9 | ``` 10 | pizza insights contributors url... [flags] 11 | ``` 12 | 13 | ### Options 14 | 15 | ``` 16 | -f, --file string Path to yaml file containing an array of git repository urls 17 | -h, --help help for contributors 18 | -r, --range int Number of days to look-back (7,30,90) (default 30) 19 | ``` 20 | 21 | ### Options inherited from parent commands 22 | 23 | ``` 24 | -c, --config string The codeowners config 25 | --disable-telemetry Disable sending telemetry data to OpenSauced 26 | -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") 27 | -o, --output string The formatting for command output. One of: (table, yaml, csv, json) (default "table") 28 | --tty-disable Disable log stylization. Suitable for CI/CD and automation 29 | ``` 30 | 31 | ### SEE ALSO 32 | 33 | * [pizza insights](pizza_insights.md) - Gather insights about git contributors, repositories, users and pull requests 34 | 35 | -------------------------------------------------------------------------------- /docs/pizza_insights_repositories.md: -------------------------------------------------------------------------------- 1 | ## pizza insights repositories 2 | 3 | Gather insights about indexed git repositories 4 | 5 | ### Synopsis 6 | 7 | Gather insights about indexed git repositories. This command will show info about contributors, pull requests, etc. 8 | 9 | ``` 10 | pizza insights repositories url... [flags] 11 | ``` 12 | 13 | ### Options 14 | 15 | ``` 16 | -f, --file string Path to yaml file containing an array of git repository urls 17 | -h, --help help for repositories 18 | -p, --range int Number of days to look-back (default 30) 19 | ``` 20 | 21 | ### Options inherited from parent commands 22 | 23 | ``` 24 | -c, --config string The codeowners config 25 | --disable-telemetry Disable sending telemetry data to OpenSauced 26 | -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") 27 | -o, --output string The formatting for command output. One of: (table, yaml, csv, json) (default "table") 28 | --tty-disable Disable log stylization. Suitable for CI/CD and automation 29 | ``` 30 | 31 | ### SEE ALSO 32 | 33 | * [pizza insights](pizza_insights.md) - Gather insights about git contributors, repositories, users and pull requests 34 | 35 | -------------------------------------------------------------------------------- /docs/pizza_insights_user-contributions.md: -------------------------------------------------------------------------------- 1 | ## pizza insights user-contributions 2 | 3 | Gather insights on individual contributors for given repo URLs 4 | 5 | ### Synopsis 6 | 7 | Gather insights on individual contributors given a list of repository URLs 8 | 9 | ``` 10 | pizza insights user-contributions url... [flags] 11 | ``` 12 | 13 | ### Options 14 | 15 | ``` 16 | -f, --file string Path to yaml file containing an array of git repository urls 17 | -h, --help help for user-contributions 18 | -p, --range int32 Number of days, used for query filtering (default 30) 19 | -s, --sort string Sort user contributions by (total, commits, prs) (default "none") 20 | -u, --users strings Inclusive comma separated list of GitHub usernames to filter for 21 | ``` 22 | 23 | ### Options inherited from parent commands 24 | 25 | ``` 26 | -c, --config string The codeowners config 27 | --disable-telemetry Disable sending telemetry data to OpenSauced 28 | -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") 29 | -o, --output string The formatting for command output. One of: (table, yaml, csv, json) (default "table") 30 | --tty-disable Disable log stylization. Suitable for CI/CD and automation 31 | ``` 32 | 33 | ### SEE ALSO 34 | 35 | * [pizza insights](pizza_insights.md) - Gather insights about git contributors, repositories, users and pull requests 36 | 37 | -------------------------------------------------------------------------------- /docs/pizza_login.md: -------------------------------------------------------------------------------- 1 | ## pizza login 2 | 3 | Log into the CLI via GitHub 4 | 5 | ### Synopsis 6 | 7 | Log into the OpenSauced CLI. 8 | 9 | This command initiates the GitHub auth flow to log you into the OpenSauced CLI 10 | by launching your browser and logging in with GitHub. 11 | 12 | ``` 13 | pizza login [flags] 14 | ``` 15 | 16 | ### Options 17 | 18 | ``` 19 | -h, --help help for login 20 | ``` 21 | 22 | ### Options inherited from parent commands 23 | 24 | ``` 25 | -c, --config string The codeowners config 26 | --disable-telemetry Disable sending telemetry data to OpenSauced 27 | -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") 28 | --tty-disable Disable log stylization. Suitable for CI/CD and automation 29 | ``` 30 | 31 | ### SEE ALSO 32 | 33 | * [pizza](pizza.md) - OpenSauced CLI 34 | 35 | -------------------------------------------------------------------------------- /docs/pizza_offboard.md: -------------------------------------------------------------------------------- 1 | ## pizza offboard 2 | 3 | CAUTION: Experimental Command. Removes users from the ".sauced.yaml" config and "CODEOWNERS" files. 4 | 5 | ### Synopsis 6 | 7 | CAUTION: Experimental Command. Removes users from the \".sauced.yaml\" config and \"CODEOWNERS\" files. 8 | Requires the users' name OR email. 9 | 10 | ``` 11 | pizza offboard [flags] 12 | ``` 13 | 14 | ### Options 15 | 16 | ``` 17 | -h, --help help for offboard 18 | -p, --path string the path to the repository (required) 19 | ``` 20 | 21 | ### Options inherited from parent commands 22 | 23 | ``` 24 | -c, --config string The codeowners config 25 | --disable-telemetry Disable sending telemetry data to OpenSauced 26 | -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") 27 | --tty-disable Disable log stylization. Suitable for CI/CD and automation 28 | ``` 29 | 30 | ### SEE ALSO 31 | 32 | * [pizza](pizza.md) - OpenSauced CLI 33 | 34 | -------------------------------------------------------------------------------- /docs/pizza_version.md: -------------------------------------------------------------------------------- 1 | ## pizza version 2 | 3 | Displays the build version of the CLI 4 | 5 | ``` 6 | pizza version [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for version 13 | ``` 14 | 15 | ### Options inherited from parent commands 16 | 17 | ``` 18 | -c, --config string The codeowners config 19 | --disable-telemetry Disable sending telemetry data to OpenSauced 20 | -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") 21 | --tty-disable Disable log stylization. Suitable for CI/CD and automation 22 | ``` 23 | 24 | ### SEE ALSO 25 | 26 | * [pizza](pizza.md) - OpenSauced CLI 27 | 28 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/open-sauced/pizza-cli/v2 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.19.0 7 | github.com/charmbracelet/bubbletea v0.27.1 8 | github.com/charmbracelet/lipgloss v0.13.0 9 | github.com/cli/browser v1.3.0 10 | github.com/go-git/go-git/v5 v5.12.0 11 | github.com/jpmcb/gopherlogs v0.2.0 12 | github.com/posthog/posthog-go v1.2.21 13 | github.com/spf13/cobra v1.8.1 14 | github.com/spf13/pflag v1.0.5 15 | github.com/stretchr/testify v1.9.0 16 | golang.org/x/term v0.23.0 17 | gopkg.in/yaml.v3 v3.0.1 18 | ) 19 | 20 | require ( 21 | github.com/atotto/clipboard v0.1.4 // indirect 22 | github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect 23 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 24 | ) 25 | 26 | require ( 27 | dario.cat/mergo v1.0.0 // indirect 28 | github.com/Microsoft/go-winio v0.6.1 // indirect 29 | github.com/ProtonMail/go-crypto v1.0.0 // indirect 30 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 31 | github.com/charmbracelet/x/ansi v0.1.4 // indirect 32 | github.com/charmbracelet/x/input v0.1.0 // indirect 33 | github.com/charmbracelet/x/term v0.1.1 // indirect 34 | github.com/charmbracelet/x/windows v0.1.0 // indirect 35 | github.com/cloudflare/circl v1.3.7 // indirect 36 | github.com/cyphar/filepath-securejoin v0.2.4 // indirect 37 | github.com/davecgh/go-spew v1.1.1 // indirect 38 | github.com/emirpasic/gods v1.18.1 // indirect 39 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 40 | github.com/go-chi/chi/v5 v5.1.0 41 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 42 | github.com/go-git/go-billy/v5 v5.5.0 // indirect 43 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 44 | github.com/google/uuid v1.6.0 45 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 46 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 47 | github.com/kevinburke/ssh_config v1.2.0 // indirect 48 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 49 | github.com/mattn/go-isatty v0.0.20 // indirect 50 | github.com/mattn/go-localereader v0.0.1 // indirect 51 | github.com/mattn/go-runewidth v0.0.16 // indirect 52 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 53 | github.com/muesli/cancelreader v0.2.2 // indirect 54 | github.com/muesli/termenv v0.15.2 // indirect 55 | github.com/pjbgf/sha1cd v0.3.0 // indirect 56 | github.com/pmezard/go-difflib v1.0.0 // indirect 57 | github.com/rivo/uniseg v0.4.7 // indirect 58 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 59 | github.com/skeema/knownhosts v1.2.2 // indirect 60 | github.com/xanzy/ssh-agent v0.3.3 // indirect 61 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 62 | golang.org/x/crypto v0.21.0 // indirect 63 | golang.org/x/mod v0.12.0 // indirect 64 | golang.org/x/net v0.22.0 // indirect 65 | golang.org/x/sync v0.8.0 // indirect 66 | golang.org/x/sys v0.24.0 // indirect 67 | golang.org/x/text v0.14.0 // indirect 68 | golang.org/x/tools v0.13.0 // indirect 69 | gopkg.in/warnings.v0 v0.1.2 // indirect 70 | ) 71 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # This is a convenience script that can be downloaded from GitHub and 4 | # piped into "sh" for conveniently downloading the latest GitHub release 5 | # of the pizza CLI: 6 | # 7 | # curl -fsSL https://raw.githubusercontent.com/open-sauced/pizza-cli/main/install.sh | sh 8 | # 9 | # Warning: It may not be advisable to pipe scripts from GitHub directly into 10 | # a command line interpreter! If you do not fully trust the source, first 11 | # download the script, inspect it manually to ensure its integrity, and then 12 | # run it: 13 | # 14 | # curl -fsSL https://raw.githubusercontent.com/open-sauced/pizza-cli/main/install.sh > install.sh 15 | # vim install.sh 16 | # ./install.sh 17 | 18 | PIZZA_REPO="open-sauced/pizza-cli" 19 | ARCH="" 20 | 21 | # Detect architecture 22 | case "$(uname -m)" in 23 | x86_64) ARCH="x86_64" ;; 24 | arm64) ARCH="arm64" ;; 25 | *) echo "Unsupported architecture"; exit 1 ;; 26 | esac 27 | 28 | # Detect OS system type. Windows not supported. 29 | case "$(uname -s)" in 30 | Darwin) OSTYPE="darwin" ;; 31 | *) OSTYPE="linux" ;; 32 | esac 33 | 34 | # Fetch download URL for the architecture from the GitHub API 35 | ASSET_URL=$(curl -s https://api.github.com/repos/$PIZZA_REPO/releases/latest | \ 36 | grep -o "https:\/\/github\.com\/open-sauced\/pizza-cli\/releases\/download\/.*${OSTYPE}-${ARCH}.*") 37 | 38 | if [ -z "$ASSET_URL" ]; then 39 | echo "Could not find a binary for latest version of Pizza CLI release and architecture ${ARCH} on OS type ${OSTYPE}" 40 | exit 1 41 | fi 42 | 43 | # Download and install 44 | curl -L "${ASSET_URL}" -o ./pizza 45 | chmod +x ./pizza 46 | 47 | echo 48 | echo "Download complete. Stay saucy 🍕" 49 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | set dotenv-load 2 | 3 | # Displays this help message 4 | help: 5 | @echo "Available commands:" 6 | @just --list 7 | 8 | # Builds the go binary into the git ignored ./build/ dir for the local architecture 9 | build: 10 | #!/usr/bin/env sh 11 | echo "Building for local arch" 12 | 13 | export VERSION="${RELEASE_TAG_VERSION:-dev}" 14 | export DATETIME=$(date -u +"%Y-%m-%d-%H:%M:%S") 15 | export SHA=$(git rev-parse HEAD) 16 | 17 | go build \ 18 | -ldflags="-s -w \ 19 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.Version=${VERSION}' \ 20 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.Sha=${SHA}' \ 21 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.Datetime=${DATETIME}' \ 22 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.writeOnlyPublicPosthogKey=${POSTHOG_PUBLIC_API_KEY}'" \ 23 | -o build/pizza 24 | 25 | # Builds and installs the go binary for the local architecture. WARNING: requires sudo access 26 | install: build 27 | sudo cp "./build/pizza" "/usr/local/bin/" 28 | 29 | # Builds all build targets arcross all OS and architectures 30 | build-all: \ 31 | build \ 32 | build-container \ 33 | build-darwin-amd64 build-darwin-arm64 \ 34 | build-linux-amd64 build-linux-arm64 \ 35 | build-windows-amd64 build-windows-arm64 36 | 37 | # Builds for Darwin linux (i.e., MacOS) on amd64 architecture 38 | build-darwin-amd64: 39 | #!/usr/bin/env sh 40 | 41 | echo "Building darwin amd64" 42 | 43 | export VERSION="${RELEASE_TAG_VERSION:-dev}" 44 | export DATETIME=$(date -u +"%Y-%m-%d-%H:%M:%S") 45 | export SHA=$(git rev-parse HEAD) 46 | export CGO_ENABLED=0 47 | export GOOS="darwin" 48 | export GOARCH="amd64" 49 | 50 | go build \ 51 | -ldflags="-s -w \ 52 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.Version=${VERSION}' \ 53 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.Sha=${SHA}' \ 54 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.Datetime=${DATETIME}' \ 55 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.writeOnlyPublicPosthogKey=${POSTHOG_PUBLIC_API_KEY}'" \ 56 | -o build/pizza-${GOOS}-${GOARCH} 57 | 58 | # Builds for Darwin linux (i.e., MacOS) on arm64 architecture (i.e. Apple silicon) 59 | build-darwin-arm64: 60 | #!/usr/bin/env sh 61 | 62 | echo "Building darwin arm64" 63 | 64 | export VERSION="${RELEASE_TAG_VERSION:-dev}" 65 | export DATETIME=$(date -u +"%Y-%m-%d-%H:%M:%S") 66 | export SHA=$(git rev-parse HEAD) 67 | export CGO_ENABLED=0 68 | export GOOS="darwin" 69 | export GOARCH="arm64" 70 | 71 | go build \ 72 | -ldflags="-s -w \ 73 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.Version=${VERSION}' \ 74 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.Sha=${SHA}' \ 75 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.Datetime=${DATETIME}' \ 76 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.writeOnlyPublicPosthogKey=${POSTHOG_PUBLIC_API_KEY}'" \ 77 | -o build/pizza-${GOOS}-${GOARCH} 78 | 79 | # Builds for agnostic Linux on amd64 architecture 80 | build-linux-amd64: 81 | #!/usr/bin/env sh 82 | 83 | echo "Building linux amd64" 84 | 85 | export VERSION="${RELEASE_TAG_VERSION:-dev}" 86 | export DATETIME=$(date -u +"%Y-%m-%d-%H:%M:%S") 87 | export SHA=$(git rev-parse HEAD) 88 | export CGO_ENABLED=0 89 | export GOOS="linux" 90 | export GOARCH="amd64" 91 | 92 | go build \ 93 | -ldflags="-s -w \ 94 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.Version=${VERSION}' \ 95 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.Sha=${SHA}' \ 96 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.Datetime=${DATETIME}' \ 97 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.writeOnlyPublicPosthogKey=${POSTHOG_PUBLIC_API_KEY}'" \ 98 | -o build/pizza-${GOOS}-${GOARCH} 99 | 100 | # Builds for agnostic Linux on arm64 architecture 101 | build-linux-arm64: 102 | #!/usr/bin/env sh 103 | 104 | echo "Building linux arm64" 105 | 106 | export VERSION="${RELEASE_TAG_VERSION:-dev}" 107 | export DATETIME=$(date -u +"%Y-%m-%d-%H:%M:%S") 108 | export SHA=$(git rev-parse HEAD) 109 | export CGO_ENABLED=0 110 | export GOOS="linux" 111 | export GOARCH="arm64" 112 | 113 | go build \ 114 | -ldflags="-s -w \ 115 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.Version=${VERSION}' \ 116 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.Sha=${SHA}' \ 117 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.Datetime=${DATETIME}' \ 118 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.writeOnlyPublicPosthogKey=${POSTHOG_PUBLIC_API_KEY}'" \ 119 | -o build/pizza-${GOOS}-${GOARCH} 120 | 121 | # Builds for Windows on amd64 architecture 122 | build-windows-amd64: 123 | #!/usr/bin/env sh 124 | 125 | echo "Building windows amd64" 126 | 127 | export VERSION="${RELEASE_TAG_VERSION:-dev}" 128 | export DATETIME=$(date -u +"%Y-%m-%d-%H:%M:%S") 129 | export SHA=$(git rev-parse HEAD) 130 | export CGO_ENABLED=0 131 | export GOOS="windows" 132 | export GOARCH="amd64" 133 | 134 | go build \ 135 | -ldflags="-s -w \ 136 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.Version=${VERSION}' \ 137 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.Sha=${SHA}' \ 138 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.Datetime=${DATETIME}' \ 139 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.writeOnlyPublicPosthogKey=${POSTHOG_PUBLIC_API_KEY}'" \ 140 | -o build/pizza-${GOOS}-${GOARCH} 141 | 142 | # Builds for Windows on arm64 architecture 143 | build-windows-arm64: 144 | #!/usr/bin/env sh 145 | 146 | echo "Building windows arm64" 147 | 148 | export VERSION="${RELEASE_TAG_VERSION:-dev}" 149 | export DATETIME=$(date -u +"%Y-%m-%d-%H:%M:%S") 150 | export SHA=$(git rev-parse HEAD) 151 | export CGO_ENABLED=0 152 | export GOOS="windows" 153 | export GOARCH="arm64" 154 | 155 | go build \ 156 | -ldflags="-s -w \ 157 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.Version=${VERSION}' \ 158 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.Sha=${SHA}' \ 159 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.Datetime=${DATETIME}' \ 160 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.writeOnlyPublicPosthogKey=${POSTHOG_PUBLIC_API_KEY}'" \ 161 | -o build/pizza-${GOOS}-${GOARCH} 162 | 163 | # Builds the Docker container and tags it as "dev" 164 | build-container: 165 | #!/usr/bin/env sh 166 | 167 | echo "Building container" 168 | 169 | export VERSION="${RELEASE_TAG_VERSION:-dev}" 170 | export DATETIME=$(date -u +"%Y-%m-%d-%H:%M:%S") 171 | export SHA=$(git rev-parse HEAD) 172 | 173 | docker build \ 174 | --build-arg VERSION="${VERSION}" \ 175 | --build-arg SHA="${SHA}" \ 176 | --build-arg DATETIME="${DATETIME}" \ 177 | --build-arg POSTHOG_PUBLIC_API_KEY="${POSTHOG_PUBLIC_API_KEY}" \ 178 | -t pizza:dev . 179 | 180 | # Removes build artifacts 181 | clean: 182 | rm -rf build/ 183 | 184 | # Runs all tests 185 | test: unit-test 186 | 187 | # Runs all in-code, unit tests 188 | unit-test: 189 | go test ./... 190 | 191 | # Lints Go code via golangci-lint within Docker 192 | lint: 193 | docker run \ 194 | -t \ 195 | --rm \ 196 | -v "$(pwd)/:/app" \ 197 | -w /app \ 198 | golangci/golangci-lint:v1.60 \ 199 | golangci-lint run -v 200 | 201 | # Formats Go code via goimports 202 | format: 203 | find . -type f -name "*.go" -exec goimports -local github.com/open-sauced/pizza-cli -w {} \; 204 | 205 | # Installs the dev tools for working with this project. Requires "go", "just", and "docker" 206 | install-dev-tools: 207 | #!/usr/bin/env sh 208 | 209 | go install golang.org/x/tools/cmd/goimports@latest 210 | 211 | # Runs Go code manually through the main.go 212 | run: 213 | go run main.go 214 | 215 | # Re-generates the docs from the cobra command tree 216 | gen-docs: 217 | go run main.go docs ./docs/ 218 | 219 | # Runs all the dev tasks (like formatting, linting, building, etc.) 220 | dev: format lint test build-all 221 | 222 | # Calls the various Posthog capture events to add the Insights to the database 223 | bootstrap-telemetry: 224 | #!/usr/bin/env sh 225 | echo "Building telemetry-oneshot" 226 | 227 | go build \ 228 | -tags telemetry \ 229 | -ldflags="-s -w \ 230 | -X 'github.com/open-sauced/pizza-cli/pkg/utils.writeOnlyPublicPosthogKey=${POSTHOG_PUBLIC_API_KEY}'" \ 231 | -o build/telemetry-oneshot \ 232 | telemetry.go 233 | 234 | ./build/telemetry-oneshot 235 | 236 | rm ./build/telemetry-oneshot 237 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/open-sauced/pizza-cli/v2/cmd/root" 8 | "github.com/open-sauced/pizza-cli/v2/pkg/utils" 9 | ) 10 | 11 | func main() { 12 | rootCmd, err := root.NewRootCommand() 13 | if err != nil { 14 | log.Fatal(err) 15 | } 16 | utils.SetupRootCommand(rootCmd) 17 | err = rootCmd.Execute() 18 | if err != nil { 19 | os.Exit(1) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /npm/.gitignore: -------------------------------------------------------------------------------- 1 | # Runtime artifact from running "npm install" locally 2 | bin/pizza 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | node_modules/ 46 | jspm_packages/ 47 | 48 | # Snowpack dependency directory (https://snowpack.dev/) 49 | web_modules/ 50 | 51 | # TypeScript cache 52 | *.tsbuildinfo 53 | 54 | # Optional npm cache directory 55 | .npm 56 | 57 | # Optional eslint cache 58 | .eslintcache 59 | 60 | # Optional stylelint cache 61 | .stylelintcache 62 | 63 | # Microbundle cache 64 | .rpt2_cache/ 65 | .rts2_cache_cjs/ 66 | .rts2_cache_es/ 67 | .rts2_cache_umd/ 68 | 69 | # Optional REPL history 70 | .node_repl_history 71 | 72 | # Output of 'npm pack' 73 | *.tgz 74 | 75 | # Yarn Integrity file 76 | .yarn-integrity 77 | 78 | # dotenv environment variable files 79 | .env 80 | .env.development.local 81 | .env.test.local 82 | .env.production.local 83 | .env.local 84 | 85 | # parcel-bundler cache (https://parceljs.org/) 86 | .cache 87 | .parcel-cache 88 | 89 | # Next.js build output 90 | .next 91 | out 92 | 93 | # Nuxt.js build / generate output 94 | .nuxt 95 | dist 96 | 97 | # Gatsby files 98 | .cache/ 99 | # Comment in the public line in if your project uses Gatsby and not Next.js 100 | # https://nextjs.org/blog/next-9-1#public-directory-support 101 | # public 102 | 103 | # vuepress build output 104 | .vuepress/dist 105 | 106 | # vuepress v2.x temp and cache directory 107 | .temp 108 | .cache 109 | 110 | # Docusaurus cache and generated files 111 | .docusaurus 112 | 113 | # Serverless directories 114 | .serverless/ 115 | 116 | # FuseBox cache 117 | .fusebox/ 118 | 119 | # DynamoDB Local files 120 | .dynamodb/ 121 | 122 | # TernJS port file 123 | .tern-port 124 | 125 | # Stores VSCode versions used for testing VSCode extensions 126 | .vscode-test 127 | 128 | # yarn v2 129 | .yarn/cache 130 | .yarn/unplugged 131 | .yarn/build-state.yml 132 | .yarn/install-state.gz 133 | .pnp.* 134 | -------------------------------------------------------------------------------- /npm/README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 | Open Sauced 4 |

🍕 Pizza CLI 🍕

5 | A Go command line interface for managing code ownership and project insights with OpenSauced! 6 |
7 |
8 |
9 | 24 | 25 | --- 26 | 27 | # 📦 Install 28 | 29 | #### Homebrew 30 | 31 | ```sh 32 | brew install open-sauced/tap/pizza 33 | ``` 34 | 35 | #### NPM 36 | 37 | ```sh 38 | npm i -g pizza 39 | ``` 40 | 41 | You can also use `npx` to run one-off commands without installing anything: 42 | 43 | ```sh 44 | npx pizza@latest generate codeowners . 45 | ``` 46 | 47 | # 🍕 Pizza Action 48 | 49 | Use [the Pizza GitHub Action](https://github.com/open-sauced/pizza-action) for running `pizza` operations in GitHub CI/CD, 50 | like automated `CODEOWNERS` updating and pruning: 51 | 52 | ```yaml 53 | jobs: 54 | pizza-action: 55 | runs-on: ubuntu-latest 56 | steps: 57 | - name: Pizza Action 58 | uses: open-sauced/pizza-action@v2 59 | with: 60 | # Optional: Whether to commit and create a PR for "CODEOWNER" changes 61 | commit-and-pr: "true" 62 | # Optional: Title of the PR for review by team 63 | pr-title: "chore: update repository codeowners" 64 | ``` 65 | 66 | # 📝 Docs 67 | 68 | - [Pizza.md](./docs/pizza.md): In depth docs on each command, option, and flag. 69 | - [OpenSauced.pizza/docs](https://opensauced.pizza/docs/tools/pizza-cli/): Learn 70 | how to use the Pizza command line tool and how it works with the rest of the OpenSauced 71 | ecosystem. 72 | 73 | # ✨ Usage 74 | 75 | ## Codeowners generation 76 | 77 | Use the `codeowners` command to generate a GitHub style `CODEOWNERS` file or a more agnostic `OWNERS` file. 78 | This can be used to granularly define what experts and entities have the 79 | most context and knowledge on certain parts of a codebase. 80 | 81 | It's expected that there's a `.sauced.yaml` config file in the given path or in 82 | your home directory (as `~/.sauced.yaml`): 83 | 84 | ```sh 85 | pizza generate codeowners /path/to/local/git/repo 86 | ``` 87 | 88 | Running this command will iterate the git ref-log to determine who to set as a code 89 | owner based on the number of lines changed for that file within the given time range. 90 | The first owner is the entity with the most lines changed. This command uses a `.sauced.yaml` configuration 91 | to attribute emails in commits with the given entities in the config (like GitHub usernames or teams). 92 | See [the section on the configuration schema for more details](#-configuration-schema) 93 | 94 | ### 🚀 New in v1.4.0: Generate Config 95 | 96 | The `pizza generate config` command has been added to help you create `.sauced.yaml` configuration files for your projects. 97 | This command allows you to generate configuration files with various options: 98 | 99 | ```sh 100 | pizza generate config /path/to/local/git/repo 101 | ``` 102 | 103 | This command will iterate the git ref-log and inspect email signatures for commits 104 | and, in interactive mode, ask you to attribute those users with GitHub handles. Once finished, the resulting 105 | `.sauced.yaml` file can be used to attribute owners in a `CODEOWNERS` file during `pizza generate codeowners`. 106 | 107 | #### Flags: 108 | 109 | - `-i, --interactive`: Enter interactive mode to attribute each email manually 110 | - `-o, --output-path string`: Set the directory for the output file 111 | - `-h, --help`: Display help for the command 112 | 113 | #### Examples: 114 | 115 | 1. Generate a config file in the current directory: 116 | ```sh 117 | pizza generate config ./ 118 | ``` 119 | 120 | 2. Generate a config file interactively: 121 | ```sh 122 | pizza generate config ./ -i 123 | ``` 124 | 125 | 3. Generate a config file from the current directory and place resulting `.sauced.yaml` in a specific output directory: 126 | ```sh 127 | pizza generate config ./ -o /path/to/directory 128 | ``` 129 | 130 | ## OpenSauced Contributor Insight from `CODEOWNERS` 131 | 132 | You can create an [OpenSauced Contributor Insight](https://opensauced.pizza/docs/features/contributor-insights/) 133 | from a local `CODEOWNERS` file: 134 | 135 | ``` 136 | pizza generate insight /path/to/repo/with/CODEOWNERS/file 137 | ``` 138 | 139 | This will parse the `CODEOWNERS` file and create a Contributor Insight on the OpenSauced platform. 140 | This allows you to track insights and metrics for those codeowners, powered by OpenSauced. 141 | 142 | ## Insights 143 | 144 | You can get metrics and insights on repositories, contributors, and more: 145 | 146 | ``` 147 | pizza insights [sub-command] 148 | ``` 149 | 150 | This powerful command lets you compose many metrics and insights together, all 151 | powered by OpenSauced's API. Use the `--output` flag to output the results as yaml, json, csv, etc. 152 | 153 | # 🎷 Configuration schema 154 | 155 | ```yaml 156 | # Configuration for attributing commits with emails to individual entities. 157 | # Used during "pizza generate codeowners". 158 | attribution: 159 | 160 | # Keys can be GitHub usernames. 161 | jpmcb: 162 | 163 | # List of emails associated with the given GitHub login. 164 | # The commits associated with these emails will be attributed to 165 | # this GitHub login in this yaml map. Any number of emails may be listed. 166 | - john@opensauced.pizza 167 | - hello@johncodes.com 168 | 169 | # Keys may also be GitHub teams. This is useful for orchestrating multiple 170 | # people to a sole GitHub team. 171 | open-sauced/engineering: 172 | - john@opensauced.pizza 173 | - other-user@email.com 174 | - other-user@no-reply.github.com 175 | 176 | # Keys can also be agnostic names which will land as keys in "OWNERS" files 177 | # when the "--owners-style-file" flag is set. 178 | John McBride 179 | - john@opensauced.pizza 180 | 181 | # Used during codeowners generation: if there are no code owners found 182 | # for a file within the time range, the list of fallback entities 183 | # will be used 184 | attribution-fallback: 185 | - open-sauced/engineering 186 | - some-other-github-login 187 | ``` 188 | -------------------------------------------------------------------------------- /npm/bin/runner.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { spawnSync } = require("child_process"); 4 | const path = require("path"); 5 | const { name } = require("../package.json"); 6 | 7 | const command_args = process.argv.slice(2); 8 | const exeName = ["win32", "cygwin"].includes(process.platform) 9 | ? `${name}.exe` 10 | : name; 11 | const binPath = path.join(__dirname, exeName); 12 | const child = spawnSync(binPath, command_args, { stdio: "inherit" }); 13 | process.exit(child.status ?? 1); 14 | -------------------------------------------------------------------------------- /npm/install.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const https = require("https"); 3 | const path = require("path"); 4 | 5 | const packageJson = require("./package.json"); 6 | const { version, name, repository } = packageJson; 7 | 8 | async function install() { 9 | const downloadURL = getDownloadURL(); 10 | 11 | try { 12 | const binDir = path.join(__dirname, "bin"); 13 | const exeName = ["win32", "cygwin"].includes(process.platform) 14 | ? `${name}.exe` 15 | : name; 16 | const outputPath = path.join(binDir, exeName); 17 | 18 | await downloadBinary(downloadURL, outputPath); 19 | 20 | fs.chmodSync(outputPath, 0o755); 21 | } catch (error) { 22 | console.error("Installation failed:", error.message); 23 | } 24 | } 25 | 26 | function getDownloadURL() { 27 | let goOS, arch; 28 | 29 | switch (process.platform) { 30 | case "win32": 31 | case "cygwin": 32 | goOS = "windows"; 33 | break; 34 | case "darwin": 35 | goOS = "darwin"; 36 | break; 37 | case "linux": 38 | goOS = "linux"; 39 | break; 40 | default: 41 | throw new Error(`Unsupported OS: ${process.platform}`); 42 | } 43 | 44 | switch (process.arch) { 45 | case "x64": 46 | arch = "amd64"; 47 | break; 48 | case "arm64": 49 | arch = "arm64"; 50 | break; 51 | default: 52 | throw new Error(`Unsupported architecture: ${process.arch}`); 53 | } 54 | 55 | return `${repository}/releases/download/v${version}/${name}-${goOS}-${arch}`; 56 | } 57 | 58 | const downloadBinary = (url, outputPath) => { 59 | return new Promise((resolve, reject) => { 60 | https 61 | .get(url, (response) => { 62 | if (response.statusCode === 302) { 63 | resolve(downloadBinary(response.headers.location, outputPath)); 64 | } else if (response.statusCode === 200) { 65 | const file = fs.createWriteStream(outputPath); 66 | response.pipe(file); 67 | file.on("finish", () => { 68 | file.close(resolve); 69 | }); 70 | } else { 71 | reject( 72 | new Error( 73 | `Failed to download ${name}. Status code: ${response.statusCode}` 74 | ) 75 | ); 76 | } 77 | }) 78 | .on("error", reject); 79 | }); 80 | }; 81 | 82 | void install(); 83 | -------------------------------------------------------------------------------- /npm/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pizza", 3 | "version": "2.4.0-beta.1", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "pizza", 9 | "version": "2.4.0-beta.1", 10 | "hasInstallScript": true, 11 | "license": "MIT", 12 | "bin": { 13 | "pizza": "bin/runner.js" 14 | } 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /npm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pizza", 3 | "version": "2.4.0-beta.1", 4 | "description": "A command line utility for insights, metrics, and generating CODEOWNERS documentation for your open source projects", 5 | "repository": "https://github.com/open-sauced/pizza-cli", 6 | "license": "MIT", 7 | "bin": "./bin/runner.js", 8 | "scripts": { 9 | "install": "node install.js" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/user" 7 | "path/filepath" 8 | 9 | "gopkg.in/yaml.v3" 10 | ) 11 | 12 | // LoadConfig loads a configuration file at a given path. 13 | // If the provided path does not exist or doesn't contain a ".sauced.yaml" file, 14 | // "~/.sauced.yaml" from the fallback path, which is the user's home directory, is used. 15 | // 16 | // This function returns the config Spec, the location the spec was loaded from, and an error 17 | func LoadConfig(path string) (*Spec, string, error) { 18 | givenPathSpec, givenLoadedPath, givenPathErr := loadSpecAtPath(path) 19 | if givenPathErr == nil { 20 | return givenPathSpec, givenLoadedPath, nil 21 | } 22 | 23 | homePathSpec, homeLoadedPath, homePathErr := loadSpecAtHome() 24 | if homePathErr == nil { 25 | return homePathSpec, homeLoadedPath, nil 26 | } 27 | 28 | return nil, "", fmt.Errorf("could not load config at given path: %w - could not load config at home: %w", givenPathErr, homePathErr) 29 | } 30 | 31 | func loadSpecAtPath(path string) (*Spec, string, error) { 32 | config := &Spec{} 33 | 34 | absPath, err := filepath.Abs(path) 35 | if err != nil { 36 | return nil, "", fmt.Errorf("error resolving absolute path: %s - %w", path, err) 37 | } 38 | 39 | data, err := os.ReadFile(absPath) 40 | if err != nil { 41 | return nil, "", fmt.Errorf("error reading config file from given absolute path: %s - %w", absPath, err) 42 | } 43 | 44 | err = yaml.Unmarshal(data, config) 45 | if err != nil { 46 | return nil, "", fmt.Errorf("error unmarshaling config at: %s - %w", absPath, err) 47 | } 48 | 49 | return config, absPath, nil 50 | } 51 | 52 | func loadSpecAtHome() (*Spec, string, error) { 53 | usr, err := user.Current() 54 | if err != nil { 55 | return nil, "", fmt.Errorf("could not get user home directory: %w", err) 56 | } 57 | 58 | path := filepath.Join(usr.HomeDir, ".sauced.yaml") 59 | conf, loadedPath, err := loadSpecAtPath(path) 60 | if err != nil { 61 | return nil, "", fmt.Errorf("could not load spec at home: %s - %w", path, err) 62 | } 63 | 64 | return conf, loadedPath, nil 65 | } 66 | -------------------------------------------------------------------------------- /pkg/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestLoadConfig(t *testing.T) { 13 | t.Parallel() 14 | 15 | t.Run("Existing file", func(t *testing.T) { 16 | t.Parallel() 17 | tmpDir := t.TempDir() 18 | configFilePath := filepath.Join(tmpDir, ".sauced.yaml") 19 | 20 | fileContents := `# Configuration for attributing commits with emails to GitHub user profiles 21 | # Used during codeowners generation. 22 | # List the emails associated with the given username. 23 | # The commits associated with these emails will be attributed to 24 | # the username in this yaml map. Any number of emails may be listed. 25 | attribution: 26 | brandonroberts: 27 | - robertsbt@gmail.com 28 | jpmcb: 29 | - john@opensauced.pizza` 30 | 31 | require.NoError(t, os.WriteFile(configFilePath, []byte(fileContents), 0600)) 32 | 33 | config, _, err := LoadConfig(configFilePath) 34 | require.NoError(t, err) 35 | assert.NotNil(t, config) 36 | 37 | // Assert that config contains all the Attributions in fileContents 38 | assert.Len(t, config.Attributions, 2) 39 | 40 | // Check specific attributions 41 | assert.Equal(t, []string{"robertsbt@gmail.com"}, config.Attributions["brandonroberts"]) 42 | assert.Equal(t, []string{"john@opensauced.pizza"}, config.Attributions["jpmcb"]) 43 | }) 44 | 45 | t.Run("Non-existent file", func(t *testing.T) { 46 | t.Parallel() 47 | tmpDir := t.TempDir() 48 | nonExistentPath := filepath.Join(tmpDir, ".sauced.yaml") 49 | 50 | config, _, err := LoadConfig(nonExistentPath) 51 | require.Error(t, err) 52 | assert.Nil(t, config) 53 | }) 54 | 55 | t.Run("Providing a custom .sauced.yaml location", func(t *testing.T) { 56 | t.Parallel() 57 | fileContents := `# Configuration for attributing commits with emails to GitHub user profiles 58 | # Used during codeowners generation. 59 | # List the emails associated with the given username. 60 | # The commits associated with these emails will be attributed to 61 | # the username in this yaml map. Any number of emails may be listed. 62 | attribution: 63 | brandonroberts: 64 | - robertsbt@gmail.com 65 | jpmcb: 66 | - john@opensauced.pizza 67 | nickytonline: 68 | - nick@nickyt.co 69 | - nick@opensauced.pizza 70 | zeucapua: 71 | - coding@zeu.dev` 72 | 73 | tmpDir := t.TempDir() 74 | fallbackPath := filepath.Join(tmpDir, ".sauced.yaml") 75 | require.NoError(t, os.WriteFile(fallbackPath, []byte(fileContents), 0600)) 76 | 77 | // Print out the contents of the file we just wrote 78 | _, err := os.ReadFile(fallbackPath) 79 | require.NoError(t, err) 80 | 81 | config, _, err := LoadConfig(fallbackPath) 82 | 83 | require.NoError(t, err) 84 | assert.NotNil(t, config) 85 | 86 | // Assert that config contains all the Attributions in fileContents 87 | assert.Len(t, config.Attributions, 4) 88 | 89 | // Check specific attributions 90 | assert.Equal(t, []string{"robertsbt@gmail.com"}, config.Attributions["brandonroberts"]) 91 | assert.Equal(t, []string{"john@opensauced.pizza"}, config.Attributions["jpmcb"]) 92 | assert.Equal(t, []string{"nick@nickyt.co", "nick@opensauced.pizza"}, config.Attributions["nickytonline"]) 93 | assert.Equal(t, []string{"coding@zeu.dev"}, config.Attributions["zeucapua"]) 94 | }) 95 | } 96 | -------------------------------------------------------------------------------- /pkg/config/file.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | ) 8 | 9 | const ( 10 | configDir = ".pizza-cli" 11 | ) 12 | 13 | // GetConfigDirectory gets the config directory path for the Pizza CLI. 14 | // This function should be used to ensure consistency among commands for loading 15 | // and modifying the config. 16 | func GetConfigDirectory() (string, error) { 17 | homeDir, err := os.UserHomeDir() 18 | if err != nil { 19 | return "", fmt.Errorf("couldn't get user home directory: %w", err) 20 | } 21 | 22 | dirName := path.Join(homeDir, configDir) 23 | 24 | // Check if the directory already exists. If not, create it. 25 | _, err = os.Stat(dirName) 26 | if os.IsNotExist(err) { 27 | if err := os.MkdirAll(dirName, os.ModePerm); err != nil { 28 | return "", fmt.Errorf(".pizza-cli directory could not be created: %w", err) 29 | } 30 | } else if err != nil { 31 | return "", fmt.Errorf("error checking ~/.pizza-cli directory: %w", err) 32 | } 33 | 34 | return dirName, nil 35 | } 36 | -------------------------------------------------------------------------------- /pkg/config/spec.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // The configuration specification 4 | type Spec struct { 5 | 6 | // Attributions are mappings of GitHub usernames to a list of emails. These 7 | // emails should be the associated addresses used by individual GitHub users. 8 | // Example: { github_username: [ email1@domain.com, email2@domain.com ]} where 9 | // "github_username" has 2 emails attributed to them and their work. 10 | Attributions map[string][]string `yaml:"attribution"` 11 | 12 | // AttributionFallback is the default username/group(s) to attribute to the filename 13 | // if no other attributions were found. 14 | AttributionFallback []string `yaml:"attribution-fallback"` 15 | } 16 | -------------------------------------------------------------------------------- /pkg/constants/api.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | const ( 4 | EndpointProd = "https://api.opensauced.pizza" 5 | EndpointBeta = "https://beta.api.opensauced.pizza" 6 | EndpointTools = "https://opensauced.tools" 7 | ) 8 | -------------------------------------------------------------------------------- /pkg/constants/flags.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | const ( 4 | FlagNameBeta = "beta" 5 | FlagNameEndpoint = "endpoint" 6 | FlagNameFile = "file" 7 | FlagNameOutput = "output" 8 | FlagNameRange = "range" 9 | FlagNameTelemetry = "disable-telemetry" 10 | FlagNameWait = "wait" 11 | ) 12 | -------------------------------------------------------------------------------- /pkg/constants/output.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | const ( 4 | OutputJSON = "json" 5 | OutputTable = "table" 6 | OutputYAML = "yaml" 7 | OuputCSV = "csv" 8 | ) 9 | -------------------------------------------------------------------------------- /pkg/constants/templates.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | const ( 4 | HelpTemplate = ` 5 | {{with (or .Long .Short)}}{{. | trimTrailingWhitespaces}} 6 | {{end}}{{if or .Runnable .HasSubCommands}}{{.UsageString}}{{end}}` 7 | 8 | // UsageTemplate is identical to the default cobra usage template, 9 | // but utilizes wrappedFlagUsages to ensure flag usages don't wrap around 10 | UsageTemplate = ` 11 | Usage:{{if .Runnable}} 12 | {{.UseLine}}{{end}}{{if gt (len .Aliases) 0}} 13 | 14 | Aliases: 15 | {{.NameAndAliases}}{{end}}{{if .HasExample}} 16 | 17 | Examples: 18 | {{.Example}}{{end}}{{if .HasAvailableSubCommands}} 19 | 20 | Available Commands:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}} 21 | {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} 22 | 23 | Flags: 24 | {{wrappedFlagUsages .LocalFlags | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} 25 | 26 | Global Flags: 27 | {{wrappedFlagUsages .InheritedFlags | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}} 28 | 29 | Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} 30 | {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}} 31 | 32 | Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}} 33 | ` 34 | ) 35 | -------------------------------------------------------------------------------- /pkg/logging/constants.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | var ( 4 | LogError = 1 5 | LogWarn = 2 6 | LogInfo = 3 7 | LogDebug = 4 8 | ) 9 | -------------------------------------------------------------------------------- /pkg/utils/arguments.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | 6 | "gopkg.in/yaml.v3" 7 | ) 8 | 9 | func HandleRepositoryValues(repos []string, filePath string) (map[string]struct{}, error) { 10 | uniqueRepoURLs := make(map[string]struct{}) 11 | for _, repo := range repos { 12 | uniqueRepoURLs[repo] = struct{}{} 13 | } 14 | if filePath != "" { 15 | file, err := os.ReadFile(filePath) 16 | if err != nil { 17 | return nil, err 18 | } 19 | var reposFromYaml []string 20 | err = yaml.Unmarshal(file, &reposFromYaml) 21 | if err != nil { 22 | return nil, err 23 | } 24 | for _, repo := range reposFromYaml { 25 | uniqueRepoURLs[repo] = struct{}{} 26 | } 27 | } 28 | return uniqueRepoURLs, nil 29 | } 30 | -------------------------------------------------------------------------------- /pkg/utils/github.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | ) 8 | 9 | // GetOwnerAndRepoFromURL: extracts the owner and repository name 10 | func GetOwnerAndRepoFromURL(input string) (owner, repo string, err error) { 11 | var repoOwner, repoName string 12 | 13 | // check (https://github.com/owner/repo) format 14 | u, err := url.Parse(input) 15 | if err == nil && u.Host == "github.com" { 16 | path := strings.Trim(u.Path, "/") 17 | parts := strings.Split(path, "/") 18 | if len(parts) != 2 { 19 | return "", "", fmt.Errorf("invalid URL: %s", input) 20 | } 21 | repoOwner = parts[0] 22 | repoName = parts[1] 23 | return repoOwner, repoName, nil 24 | } 25 | 26 | // check (owner/repo) format 27 | parts := strings.Split(input, "/") 28 | if len(parts) != 2 { 29 | return "", "", fmt.Errorf("invalid URL: %s", input) 30 | } 31 | repoOwner = parts[0] 32 | repoName = parts[1] 33 | 34 | return repoOwner, repoName, nil 35 | } 36 | -------------------------------------------------------------------------------- /pkg/utils/output.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | 7 | bubblesTable "github.com/charmbracelet/bubbles/table" 8 | "github.com/charmbracelet/lipgloss" 9 | "gopkg.in/yaml.v3" 10 | ) 11 | 12 | func OutputJSON(entity interface{}) (string, error) { 13 | output, err := json.MarshalIndent(entity, "", " ") 14 | if err != nil { 15 | return "", err 16 | } 17 | return string(output), nil 18 | } 19 | 20 | func OutputYAML(entity interface{}) (string, error) { 21 | output, err := yaml.Marshal(entity) 22 | if err != nil { 23 | return "", err 24 | } 25 | return strings.TrimSuffix(string(output), "\n"), nil 26 | } 27 | 28 | func OutputTable(rows []bubblesTable.Row, columns []bubblesTable.Column) string { 29 | styles := bubblesTable.Styles{ 30 | Cell: lipgloss.NewStyle().PaddingRight(1), 31 | Header: lipgloss.NewStyle().Bold(true).PaddingRight(1), 32 | Selected: lipgloss.NewStyle(), 33 | } 34 | table := bubblesTable.New( 35 | bubblesTable.WithRows(rows), 36 | bubblesTable.WithColumns(columns), 37 | bubblesTable.WithHeight(len(rows)), 38 | bubblesTable.WithStyles(styles), 39 | ) 40 | return table.View() 41 | } 42 | 43 | func GetMaxTableRowWidth(rows []bubblesTable.Row) int { 44 | var maxRowWidth int 45 | for i := range rows { 46 | rowWidth := len(rows[i][0]) 47 | if rowWidth > maxRowWidth { 48 | maxRowWidth = rowWidth 49 | } 50 | } 51 | return maxRowWidth 52 | } 53 | -------------------------------------------------------------------------------- /pkg/utils/posthog.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/posthog/posthog-go" 7 | ) 8 | 9 | var ( 10 | writeOnlyPublicPosthogKey = "dev" 11 | posthogEndpoint = "https://us.i.posthog.com" 12 | ) 13 | 14 | // PosthogCliClient is a wrapper around the posthog-go client and is used as a 15 | // API entrypoint for sending OpenSauced telemetry data for CLI commands 16 | type PosthogCliClient struct { 17 | // client is the Posthog Go client 18 | client posthog.Client 19 | 20 | // activated denotes if the user has enabled or disabled telemetry 21 | activated bool 22 | 23 | // uniqueID is the user's unique, anonymous identifier 24 | uniqueID string 25 | } 26 | 27 | // NewPosthogCliClient returns a PosthogCliClient which can be used to capture 28 | // telemetry events for CLI users 29 | func NewPosthogCliClient(activated bool) *PosthogCliClient { 30 | client, err := posthog.NewWithConfig( 31 | writeOnlyPublicPosthogKey, 32 | posthog.Config{ 33 | Endpoint: posthogEndpoint, 34 | }, 35 | ) 36 | 37 | if err != nil { 38 | // Should never happen since we aren't setting posthog.Config data that 39 | // would cause its validation to fail 40 | panic(err) 41 | } 42 | 43 | uniqueID, err := getOrCreateUniqueID() 44 | if err != nil { 45 | fmt.Printf("could not build anonymous telemetry client: %s\n", err) 46 | } 47 | 48 | return &PosthogCliClient{ 49 | client: client, 50 | activated: activated, 51 | uniqueID: uniqueID, 52 | } 53 | } 54 | 55 | // Done should always be called in order to flush the Posthog buffers before 56 | // the CLI exits to ensure all events are accurately captured. 57 | func (p *PosthogCliClient) Done() error { 58 | return p.client.Close() 59 | } 60 | 61 | // CaptureLogin gathers telemetry on users who log into OpenSauced via the CLI 62 | func (p *PosthogCliClient) CaptureLogin(username string) error { 63 | if p.activated { 64 | return p.client.Enqueue(posthog.Capture{ 65 | DistinctId: username, 66 | Event: "pizza_cli_user_logged_in", 67 | }) 68 | } 69 | 70 | return nil 71 | } 72 | 73 | // CaptureFailedLogin gathers telemetry on failed logins via the CLI 74 | func (p *PosthogCliClient) CaptureFailedLogin() error { 75 | if p.activated { 76 | return p.client.Enqueue(posthog.Capture{ 77 | DistinctId: p.uniqueID, 78 | Event: "pizza_cli_user_failed_log_in", 79 | }) 80 | } 81 | 82 | return nil 83 | } 84 | 85 | // CaptureCodeownersGenerate gathers telemetry on successful codeowners generation 86 | func (p *PosthogCliClient) CaptureCodeownersGenerate() error { 87 | if p.activated { 88 | return p.client.Enqueue(posthog.Capture{ 89 | DistinctId: p.uniqueID, 90 | Event: "pizza_cli_generated_codeowners", 91 | }) 92 | } 93 | 94 | return nil 95 | } 96 | 97 | // CaptureFailedCodeownersGenerate gathers telemetry on failed codeowners generation 98 | func (p *PosthogCliClient) CaptureFailedCodeownersGenerate() error { 99 | if p.activated { 100 | return p.client.Enqueue(posthog.Capture{ 101 | DistinctId: p.uniqueID, 102 | Event: "pizza_cli_failed_to_generate_codeowners", 103 | }) 104 | } 105 | 106 | return nil 107 | } 108 | 109 | // CaptureCodeownersGenerateAuth gathers telemetry on successful auth flows during codeowners generation 110 | func (p *PosthogCliClient) CaptureCodeownersGenerateAuth(username string) error { 111 | if p.activated { 112 | return p.client.Enqueue(posthog.Capture{ 113 | DistinctId: username, 114 | Event: "pizza_cli_user_authenticated_during_generate_codeowners_flow", 115 | }) 116 | } 117 | 118 | return nil 119 | } 120 | 121 | // CaptureFailedCodeownersGenerateAuth gathers telemetry on failed auth flows during codeowners generations 122 | func (p *PosthogCliClient) CaptureFailedCodeownersGenerateAuth() error { 123 | if p.activated { 124 | return p.client.Enqueue(posthog.Capture{ 125 | DistinctId: p.uniqueID, 126 | Event: "pizza_cli_user_failed_to_authenticate_during_generate_codeowners_flow", 127 | }) 128 | } 129 | 130 | return nil 131 | } 132 | 133 | // CaptureCodeownersGenerateContributorInsight gathers telemetry on successful 134 | // Contributor Insights creation/update during codeowners generation 135 | func (p *PosthogCliClient) CaptureCodeownersGenerateContributorInsight() error { 136 | if p.activated { 137 | return p.client.Enqueue(posthog.Capture{ 138 | DistinctId: p.uniqueID, 139 | Event: "pizza_cli_created_updated_contributor_list", 140 | }) 141 | } 142 | 143 | return nil 144 | } 145 | 146 | // CaptureFailedCodeownersGenerateContributorInsight gathers telemetry on failed 147 | // Contributor Insights during codeowners generation 148 | func (p *PosthogCliClient) CaptureFailedCodeownersGenerateContributorInsight() error { 149 | if p.activated { 150 | return p.client.Enqueue(posthog.Capture{ 151 | DistinctId: p.uniqueID, 152 | Event: "pizza_cli_failed_to_create_update_contributor_insight_for_user", 153 | }) 154 | } 155 | 156 | return nil 157 | } 158 | 159 | // CaptureConfigGenerate gathers telemetry on success 160 | func (p *PosthogCliClient) CaptureConfigGenerate() error { 161 | if p.activated { 162 | return p.client.Enqueue(posthog.Capture{ 163 | DistinctId: p.uniqueID, 164 | Event: "pizza_cli_generated_config", 165 | }) 166 | } 167 | 168 | return nil 169 | } 170 | 171 | // CaptureConfigGenerateMode gathers what mode a user is in when generating 172 | // either 'Automatic' (default) or 'Interactive' 173 | func (p *PosthogCliClient) CaptureConfigGenerateMode(mode string) error { 174 | properties := make(map[string]interface{}) 175 | 176 | properties["mode"] = mode 177 | 178 | if p.activated { 179 | return p.client.Enqueue(posthog.Capture{ 180 | DistinctId: p.uniqueID, 181 | Event: "pizza_cli_generated_config_mode", 182 | Properties: properties, 183 | }) 184 | } 185 | 186 | return nil 187 | } 188 | 189 | // CaptureFailedConfigGenerate gathers telemetry on failed 190 | func (p *PosthogCliClient) CaptureFailedConfigGenerate() error { 191 | if p.activated { 192 | return p.client.Enqueue(posthog.Capture{ 193 | DistinctId: p.uniqueID, 194 | Event: "pizza_cli_failed_to_generate_config", 195 | }) 196 | } 197 | 198 | return nil 199 | } 200 | 201 | func (p *PosthogCliClient) CaptureOffboard() error { 202 | if p.activated { 203 | return p.client.Enqueue(posthog.Capture{ 204 | DistinctId: p.uniqueID, 205 | Event: "pizza_cli_offboard", 206 | }) 207 | } 208 | 209 | return nil 210 | } 211 | 212 | func (p *PosthogCliClient) CaptureFailedOffboard() error { 213 | if p.activated { 214 | return p.client.Enqueue(posthog.Capture{ 215 | DistinctId: p.uniqueID, 216 | Event: "pizza_cli_failed_to_offboard", 217 | }) 218 | } 219 | 220 | return nil 221 | } 222 | 223 | // CaptureInsights gathers telemetry on successful Insights command runs 224 | func (p *PosthogCliClient) CaptureInsights() error { 225 | if p.activated { 226 | return p.client.Enqueue(posthog.Capture{ 227 | DistinctId: p.uniqueID, 228 | Event: "pizza_cli_called_insights_command", 229 | }) 230 | } 231 | 232 | return nil 233 | } 234 | 235 | // CaptureFailedInsights gathers telemetry on failed Insights command runs 236 | func (p *PosthogCliClient) CaptureFailedInsights() error { 237 | if p.activated { 238 | return p.client.Enqueue(posthog.Capture{ 239 | DistinctId: p.uniqueID, 240 | Event: "pizza_cli_failed_to_call_insights_command", 241 | }) 242 | } 243 | 244 | return nil 245 | } 246 | -------------------------------------------------------------------------------- /pkg/utils/root.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/spf13/cobra" 7 | "github.com/spf13/pflag" 8 | "golang.org/x/term" 9 | 10 | "github.com/open-sauced/pizza-cli/v2/pkg/constants" 11 | ) 12 | 13 | // SetupRootCommand is a convenience utility for applying templates and nice 14 | // user experience pieces to the root cobra command 15 | func SetupRootCommand(rootCmd *cobra.Command) { 16 | cobra.AddTemplateFunc("wrappedFlagUsages", wrappedFlagUsages) 17 | rootCmd.SetUsageTemplate(constants.UsageTemplate) 18 | rootCmd.SetHelpTemplate(constants.HelpTemplate) 19 | } 20 | 21 | // Uses the users terminal size or width of 80 if cannot determine users width 22 | func wrappedFlagUsages(cmd *pflag.FlagSet) string { 23 | // converts the uintptr to the system file descriptor integer 24 | //nolint:gosec 25 | fd := int(os.Stdout.Fd()) 26 | width := 80 27 | 28 | // Get the terminal width and dynamically set 29 | termWidth, _, err := term.GetSize(fd) 30 | if err == nil { 31 | width = termWidth 32 | } 33 | 34 | return cmd.FlagUsagesWrapped(width - 1) 35 | } 36 | -------------------------------------------------------------------------------- /pkg/utils/telemetry.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/google/uuid" 10 | ) 11 | 12 | var telemetryFilePath = filepath.Join(os.Getenv("HOME"), ".pizza-cli", "telemetry.json") 13 | 14 | // userTelemetryConfig is the config for the user's anonymous telemetry data 15 | type userTelemetryConfig struct { 16 | ID string `json:"id"` 17 | } 18 | 19 | // getOrCreateUniqueID reads the telemetry.json file to fetch the user's anonymous, unique ID. 20 | // In case of error (i.e., if the file doesn't exist or is invalid) it generates 21 | // a new UUID and stores it in the telemetry.json file 22 | func getOrCreateUniqueID() (string, error) { 23 | if _, err := os.Stat(telemetryFilePath); os.IsNotExist(err) { 24 | return createTelemetryUUID() 25 | } 26 | 27 | data, err := os.ReadFile(telemetryFilePath) 28 | if err != nil { 29 | return createTelemetryUUID() 30 | } 31 | 32 | // Try parsing the telemetry file 33 | var teleData userTelemetryConfig 34 | err = json.Unmarshal(data, &teleData) 35 | if err != nil || teleData.ID == "" { 36 | return createTelemetryUUID() 37 | } 38 | 39 | return teleData.ID, nil 40 | } 41 | 42 | // createTelemetryUUID generates a new UUID and writes it to the user's telemetry.json file 43 | func createTelemetryUUID() (string, error) { 44 | newUUID := uuid.New().String() 45 | 46 | teleData := userTelemetryConfig{ 47 | ID: newUUID, 48 | } 49 | 50 | data, err := json.Marshal(teleData) 51 | if err != nil { 52 | return "", fmt.Errorf("error creating telemetry data: %w", err) 53 | } 54 | 55 | err = os.MkdirAll(filepath.Dir(telemetryFilePath), 0755) 56 | if err != nil { 57 | return "", fmt.Errorf("error creating directory for telemetry file: %w", err) 58 | } 59 | 60 | err = os.WriteFile(telemetryFilePath, data, 0600) 61 | if err != nil { 62 | return "", fmt.Errorf("error writing telemetry file: %w", err) 63 | } 64 | 65 | return newUUID, nil 66 | } 67 | -------------------------------------------------------------------------------- /pkg/utils/version.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | var ( 4 | Version = "dev" 5 | Sha = "HEAD" 6 | Datetime = "dev" 7 | ) 8 | -------------------------------------------------------------------------------- /scripts/generate-docs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit immediately if a command exits with a non-zero status. 4 | set -e 5 | 6 | # Configure git to use the OpenSauced bot account 7 | git config user.name 'open-sauced[bot]' 8 | git config user.email '63161813+open-sauced[bot]@users.noreply.github.com' 9 | 10 | # Semantic release made changes, so pull the latest changes from the current branch 11 | git pull origin "$GITHUB_REF" 12 | 13 | # Generate documentation 14 | just gen-docs 15 | 16 | # Get the author of the last non-merge commit 17 | LAST_COMMIT_AUTHOR=$(git log -1 --no-merges --pretty=format:'%an <%ae>') 18 | 19 | # Commit with co-authorship and push changes 20 | git add docs/ 21 | git commit -m "chore: automated docs generation for release 22 | 23 | Co-authored-by: $LAST_COMMIT_AUTHOR" || echo "No changes to commit" 24 | git push origin HEAD:"$GITHUB_REF" 25 | -------------------------------------------------------------------------------- /telemetry.go: -------------------------------------------------------------------------------- 1 | //go:build telemetry 2 | // +build telemetry 3 | 4 | package main 5 | 6 | import "github.com/open-sauced/pizza-cli/v2/pkg/utils" 7 | 8 | // This alternate main is used as a one-shot for bootstrapping Posthog events: 9 | // the various events called herein do not exist in Posthog's datalake until the 10 | // event has landed. 11 | // 12 | // Therefore, this is useful for when there are new events for Posthog that need 13 | // a dashboard bootstrapped for them. 14 | 15 | func main() { 16 | println("Started bootstrapping Posthog events") 17 | client := utils.NewPosthogCliClient(true) 18 | 19 | err := client.CaptureLogin("test-user") 20 | if err != nil { 21 | panic(err) 22 | } 23 | 24 | err = client.CaptureFailedLogin() 25 | if err != nil { 26 | panic(err) 27 | } 28 | 29 | err = client.CaptureCodeownersGenerate() 30 | if err != nil { 31 | panic(err) 32 | } 33 | 34 | err = client.CaptureFailedCodeownersGenerate() 35 | if err != nil { 36 | panic(err) 37 | } 38 | 39 | err = client.CaptureConfigGenerate() 40 | if err != nil { 41 | panic(err) 42 | } 43 | 44 | err = client.CaptureFailedConfigGenerate() 45 | if err != nil { 46 | panic(err) 47 | } 48 | 49 | err = client.CaptureConfigGenerateMode("interactive") 50 | if err != nil { 51 | panic(err) 52 | } 53 | 54 | err = client.CaptureCodeownersGenerateAuth("test-user") 55 | if err != nil { 56 | panic(err) 57 | } 58 | 59 | err = client.CaptureFailedCodeownersGenerateAuth() 60 | if err != nil { 61 | panic(err) 62 | } 63 | 64 | err = client.CaptureCodeownersGenerateContributorInsight() 65 | if err != nil { 66 | panic(err) 67 | } 68 | 69 | err = client.CaptureFailedCodeownersGenerateContributorInsight() 70 | if err != nil { 71 | panic(err) 72 | } 73 | 74 | err = client.CaptureInsights() 75 | if err != nil { 76 | panic(err) 77 | } 78 | 79 | err = client.CaptureFailedInsights() 80 | if err != nil { 81 | panic(err) 82 | } 83 | 84 | err = client.Done() 85 | if err != nil { 86 | panic(err) 87 | } 88 | 89 | println("Done bootstrapping Posthog events") 90 | } 91 | --------------------------------------------------------------------------------