├── .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 | 
51 |
52 | 
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 |

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 |
--------------------------------------------------------------------------------