├── .github └── workflows │ ├── docker.yml │ └── go.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── cmd ├── cache │ └── cache.go ├── common │ ├── common.go │ └── test.go ├── config │ └── main.go ├── info │ └── main.go ├── projects │ ├── branches.go │ ├── edit.go │ ├── files.go │ ├── list.go │ ├── mr.go │ ├── projects.go │ ├── protected_branches.go │ ├── registry.go │ └── schedules.go ├── users │ ├── block.go │ ├── create.go │ ├── delete.go │ ├── list.go │ ├── modify.go │ ├── search.go │ ├── search_test.go │ ├── users.go │ ├── users_test.go │ └── whoami.go └── versions │ └── main.go ├── config.yaml ├── go.mod ├── go.sum ├── main.go └── pkg ├── client └── client.go ├── config ├── cache.go └── config.go ├── limiter └── limiter.go ├── sort ├── sort.go ├── util.go └── v2 │ ├── sort.go │ └── util.go └── util ├── dict.go ├── pointers.go ├── util.go ├── values.go └── version.go /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image 2 | 3 | on: 4 | push: 5 | tags: 6 | - '**' 7 | 8 | jobs: 9 | 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Docker meta 17 | id: meta 18 | uses: docker/metadata-action@v5 19 | with: 20 | images: ghcr.io/${{ github.repository }} 21 | tags: | 22 | type=schedule 23 | type=ref,event=branch 24 | type=ref,event=pr 25 | type=semver,pattern={{version}} 26 | type=semver,pattern={{major}}.{{minor}} 27 | type=semver,pattern={{major}} 28 | type=sha 29 | 30 | - name: Set up Docker Buildx 31 | id: buildx 32 | uses: docker/setup-buildx-action@v3 33 | 34 | - name: Login to Github Packages 35 | uses: docker/login-action@v3 36 | with: 37 | registry: ghcr.io 38 | username: ${{ github.repository_owner }} 39 | password: ${{ secrets.GITHUB_TOKEN }} 40 | 41 | - name: Set build date 42 | run: echo "BUILD_DATE=$(date +%Y%m%d%H%M)" >> $GITHUB_ENV 43 | 44 | - name: Build and push 45 | uses: docker/build-push-action@v5 46 | with: 47 | context: . 48 | build-args: | 49 | versionflags=-X 'github.com/flant/glaball/pkg/util.Version=${{ github.ref_name }}' -X 'github.com/flant/glaball/pkg/util.Revision=${{ github.sha }}' -X 'github.com/flant/glaball/pkg/util.Branch=${{ github.ref_name }}' -X 'github.com/flant/glaball/pkg/util.BuildUser=${{ github.actor }}' -X 'github.com/flant/glaball/pkg/util.BuildDate=${{ env.BUILD_DATE }}' 50 | push: ${{ github.event_name != 'pull_request' }} 51 | tags: ${{ steps.meta.outputs.tags }} 52 | labels: ${{ steps.meta.outputs.labels }} 53 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | tags: 6 | - '**' 7 | 8 | jobs: 9 | 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version: 1.23 20 | 21 | - name: Build 22 | run: | 23 | versionflags="-X 'github.com/flant/glaball/pkg/util.Version=$GITHUB_REF_NAME' -X 'github.com/flant/glaball/pkg/util.Revision=$GITHUB_SHA' -X 'github.com/flant/glaball/pkg/util.Branch=$GITHUB_REF_NAME' -X 'github.com/flant/glaball/pkg/util.BuildUser=$GITHUB_ACTOR' -X 'github.com/flant/glaball/pkg/util.BuildDate=$(date +%Y%m%d%H%M)'" 24 | for GOOS in darwin linux windows; do 25 | for GOARCH in amd64 arm64; do 26 | export GOOS GOARCH 27 | CGO_ENABLED=0 go build -v -a -tags netgo -ldflags="-extldflags '-static' -s -w $versionflags" -o build/glaball-${GOOS}-${GOARCH} *.go 28 | if [[ $GOOS == "windows" ]]; then mv build/glaball-${GOOS}-${GOARCH} build/glaball-${GOOS}-${GOARCH}.exe; fi 29 | done 30 | done 31 | cd build; sha256sum * > sha256sums.txt 32 | 33 | - name: Release 34 | uses: softprops/action-gh-release@v1 35 | with: 36 | generate_release_notes: true 37 | token: ${{ secrets.GITHUB_TOKEN }} 38 | files: | 39 | build/* 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | # Go workspace file 18 | go.work 19 | 20 | .vscode 21 | build/ 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23-bullseye as builder 2 | 3 | ARG versionflags 4 | 5 | WORKDIR /src 6 | 7 | COPY . . 8 | 9 | RUN CGO_ENABLED=0 go build -v -a -tags netgo -ldflags="-extldflags '-static' -s -w $versionflags" -o build/glaball *.go 10 | 11 | 12 | FROM debian:bullseye-slim 13 | 14 | ENV DEBIAN_FRONTEND=noninteractive 15 | 16 | RUN apt-get update && apt-get install -qy --no-install-recommends \ 17 | ca-certificates 18 | 19 | COPY --from=builder /src/build/glaball /usr/local/bin/glaball 20 | 21 | CMD [ "/usr/local/bin/glaball" ] 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Go](https://github.com/flant/glaball/actions/workflows/go.yml/badge.svg)](https://github.com/flant/glaball/actions/workflows/go.yml) 2 | [![Docker Image](https://github.com/flant/glaball/actions/workflows/docker.yml/badge.svg)](https://github.com/flant/glaball/actions/workflows/docker.yml) 3 | 4 | Glaball is a CLI tool to manage multiple self-hosted GitLab instances at the same time. [This announcement](https://blog.flant.com/glaball-to-manage-gitlab-instances-in-bulk/) tells the story why it was created. 5 | 6 | **Contents**: 7 | * [Features](#features) 8 | * [Installing](#installing) 9 | * [Building](#building) 10 | * [Configuring](#configuring) 11 | * [How to add a GitLab host?](#how-to-add-a-gitlab-host) 12 | * [Important note on security](#important-note-on-security) 13 | * [Usage](#usage) 14 | * [Autocompletion](#autocompletion) 15 | * [Usage examples](#usage-examples) 16 | * [Community](#community) 17 | * [License](#license) 18 | 19 | ## Features 20 | 21 | Glaball currently supports the following features: 22 | 23 | * Creating/blocking/deleting/modifying users (`users [create|block|delete|modify]`) 24 | * Displaying a list of users with sorting/grouping/filtering options (`users list`) 25 | * Searching for a specific user (`users search`) 26 | * Displaying a list of repositories with sorting/grouping/filtering options (`projects list`) 27 | * Editing some repository options (`projects edit`) 28 | * Searching for scheduled jobs in repositories and filtering by active/inactive status (`projects pipelines schedules`) 29 | * Searching for the regex pattern in the specified files in the repositories (`projects files search`) 30 | * Displaying a list of current GitLab instance versions with information on whether an update is necessary (up to date|update available|update asap) 31 | * Displaying information about the current API user (`whoami`) 32 | 33 | ## Installing 34 | 35 | You can get the compiled binary at [the releases page](https://github.com/flant/glaball/releases). 36 | 37 | ### Building 38 | 39 | ``` 40 | $ git clone https://github.com/flant/glaball.git 41 | $ cd glaball 42 | $ go build -v -o build/glaball *.go 43 | ``` 44 | 45 | ### Configuring 46 | 47 | ``` 48 | $ cat ~/.config/glaball/config.yaml 49 | 50 | # Cache settings 51 | cache: 52 | # HTTP request cache is enabled by default 53 | enabled: true 54 | # The cache is stored in the user's cache directory by default 55 | # $HOME/.cache/glaball 56 | path: "" 57 | # The default cache size is 100MB 58 | size: 100MB 59 | # GZIP cache compression is enabled by default 60 | compression: true 61 | # By default, the cache is valid for 1 day 62 | ttl: 24h 63 | 64 | # By default, operations are performed for all hosts. 65 | # You can use a regexp command/project filter (e.g., "main.*" or "main.example-project") 66 | # at the config level or use the --filter flag (-f) 67 | filter: ".*" 68 | 69 | # By default, count of hosts in grouped output is limited to 5. 70 | # If you want to show all hosts, set this option to true or use the --all flag (-a). 71 | all: false 72 | 73 | # The number of simultaneous HTTP connections 74 | threads: 100 75 | 76 | # Host list (required) 77 | # The project name is generated as follows: ".." 78 | hosts: 79 | # The team (e.g., main) 80 | team: 81 | # The project name (e.g., example-project) 82 | project: 83 | # The GitLab name 84 | name: 85 | # Link to the project's GitLab repo 86 | url: https://gitlab.example.com 87 | # Custom IP address of host 88 | # If you want to override IP address resolved by the default resolver, use this option. 89 | ip: 127.0.0.1 90 | # The user's token the client will use to connect to the API 91 | # API token - provides full permissions, including user creation/deletion 92 | # Read API token - provides read-only access without permissions to create/modify users or filtering the list of users by their email address 93 | token: 94 | # Rate limit check 95 | # This one is disabled by default if a cache is used because it generates additional non-cacheable requests. 96 | # It only makes sense to enable it if the rate limit in GitLab is enabled 97 | # https://docs.gitlab.com/ee/security/rate_limits.html 98 | rate_limit: false 99 | ``` 100 | 101 | ### How to add a GitLab host? 102 | - Go to https://gitlab.example.com/-/profile/personal_access_tokens as a user; 103 | - Create a **Personal Access Token** named `glaball` with the following scope: 104 | - `read_api` - if only read access is required; 105 | - `api` - if full access is required. This one allows you to create users and so forth (note that the user must have the admin privileges); 106 | - Add your host and a token to the `config.yaml` file and copy it to the glaball directory (or specify the path to the config file via the `--config=path` parameter). 107 | 108 | Example: 109 | ``` 110 | hosts: 111 | main: 112 | example-project: 113 | primary: 114 | url: https://gitlab-primary.example.com 115 | token: api_token 116 | secondary: 117 | url: https://gitlab-secondary.example.com 118 | token: api_token 119 | ``` 120 | 121 | Let's check that the host is in the config: 122 | ``` 123 | $ glaball config list 124 | [main.example-project.secondary] https://gitlab-secondary.example.com 125 | [main.example-project.primary] https://gitlab-primary.example.com 126 | Total: 2 127 | ``` 128 | 129 | ### Important note on security 130 | 131 | Currently, all access tokens are simply stored in the glaball configuration file from where they can be used to access relevant GitLab instances. Remember to set appropriate permissions on your config, use `read_api` tokens only (if possible), and apply a strict expiry policy for your tokens. 132 | 133 | ## Usage 134 | 135 | ``` 136 | $ glaball -h 137 | Gitlab bulk administration tool 138 | 139 | Usage: 140 | glaball [flags] 141 | glaball [command] 142 | 143 | Available Commands: 144 | cache Cache management 145 | completion Generate the autocompletion script for the specified shell 146 | config Information about the current configuration 147 | help Help about any command 148 | info Information about the current build 149 | projects Projects API 150 | users Users API 151 | versions Retrieve version information for GitLab instances 152 | whoami Current API user 153 | 154 | Flags: 155 | -a, --all Show all hosts in grouped output 156 | --config string Path to the configuration file. (default "$HOME/.config/glaball/config.yaml") 157 | -f, --filter string Select Gitlab(s) by regexp filter (default ".*") 158 | -h, --help help for glaball 159 | --log_level string Only log messages with the given severity or above. Valid levels: [debug, info, warn, error, off] (default "info") 160 | --threads int Number of concurrent processes. (default: one process for each Gitlab instances in config file) (default 100) 161 | --ttl duration Override cache TTL set in config file (default 24h0m0s) 162 | -u, --update Refresh cache 163 | -v, --verbose Verbose output 164 | 165 | Use "glaball [command] --help" for more information about a command. 166 | ``` 167 | 168 | ### Autocompletion 169 | 170 | #### Bash 171 | ``` 172 | $ glaball completion bash -h 173 | 174 | Generate the autocompletion script for the bash shell. 175 | 176 | This script depends on the 'bash-completion' package. 177 | If it is not installed already, you can install it via your OS's package manager. 178 | 179 | To load completions in your current shell session: 180 | $ source <(glaball completion bash) 181 | 182 | To load completions for every new session, execute once: 183 | Linux: 184 | $ glaball completion bash > /etc/bash_completion.d/glaball 185 | MacOS: 186 | $ glaball completion bash > /usr/local/etc/bash_completion.d/glaball 187 | 188 | You will need to start a new shell for this setup to take effect. 189 | ``` 190 | 191 | #### Zsh 192 | 193 | ``` 194 | $ glaball completion zsh -h 195 | 196 | Generate the autocompletion script for the zsh shell. 197 | 198 | If shell completion is not already enabled in your environment you will need 199 | to enable it. You can execute the following once: 200 | 201 | $ echo "autoload -U compinit; compinit" >> ~/.zshrc 202 | 203 | To load completions for every new session, execute once: 204 | # Linux: 205 | $ glaball completion zsh > "${fpath[1]}/_glaball" 206 | # macOS: 207 | $ glaball completion zsh > /usr/local/share/zsh/site-functions/_glaball 208 | 209 | You will need to start a new shell for this setup to take effect. 210 | ``` 211 | 212 | ## Usage examples 213 | 214 | ### Create a user 215 | Here is how you can create a new user in `main` team projects: 216 | 217 | ``` 218 | $ glaball users create --filter "main.*" --email=test@test.com --username=test-glaball --name="test glaball" --password="qwerty" 219 | ``` 220 | 221 | ### Block a user 222 | ``` 223 | $ glaball users block --by=username test-glaball 224 | ``` 225 | 226 | Display the list of projects in which this user exists: 227 | ``` 228 | $ glaball users block --by=username test-glaball --hosts 229 | ``` 230 | 231 | ### Edit projects options 232 | ``` 233 | $ glaball projects edit --search_namespaces=true --search mygroup/ --ci_forward_deployment_enabled=false 234 | ``` 235 | 236 | ### List opened merge requests 237 | ``` 238 | $ glaball projects mr list 239 | ``` 240 | 241 | ### Protect main branch in all projects 242 | ``` 243 | $ glaball projects branches protected protect --name="main" --merge_access_level=30 244 | ``` 245 | 246 | ### Search for a pattern in the files 247 | *You can search through several files at once, but that (at least) doubles the search time.* 248 | 249 | *You can search for several patterns at once; it does not affect the search time.* 250 | 251 | ``` 252 | $ glaball projects files search --filepath="werf.yaml,werf.yml" --pattern="mount" --show 253 | ``` 254 | 255 | ### Search for scheduled pipelines 256 | The command below displays a list of projects with inactive scheduled pipelines: 257 | ``` 258 | $ glaball -f "main.*" projects pipelines schedules --active=false 259 | ``` 260 | 261 | ### List and create [werf](https://github.com/werf/werf) cleanup [schedules](https://werf.io/documentation/v1.2/advanced/ci_cd/gitlab_ci_cd.html#cleaning-up-images) 262 | List all cleanup schedules including non-existent: 263 | ``` 264 | $ glaball projects pipelines cleanups 265 | ``` 266 | 267 | Create cleanup schedules in all projects (only **one** gitlab host): 268 | ``` 269 | $ glaball projects pipelines cleanups -f "main.example-project.primary" --create --setowner ${WERF_IMAGES_CLEANUP_PASSWORD} 270 | ``` 271 | 272 | Change the owner of existing cleanup scheduled pipelines (only **one** gitlab host): 273 | ``` 274 | $ glaball projects pipelines cleanups -f "main.example-project.primary" --setowner ${WERF_IMAGES_CLEANUP_PASSWORD} 275 | ``` 276 | 277 | ### Get information about the projects' container registries 278 | ``` 279 | $ glaball projects registry list --size | sort -k 4 -h 280 | ``` 281 | 282 | ### Search for a user 283 | ``` 284 | $ glaball users search --by=username docker 285 | ``` 286 | 287 | ### List users 288 | Display the list of users grouped by the username: 289 | ``` 290 | $ glaball users list --group_by=username 291 | ``` 292 | 293 | Show only those users who are active in *n* projects: 294 | ``` 295 | $ glaball users list --group_by=username --count n --all 296 | ``` 297 | 298 | Display the list of administrators: 299 | ``` 300 | $ glaball users list --group_by=username --admins=true 301 | ``` 302 | 303 | ### Show the list of hosts specified in the config 304 | ``` 305 | $ glaball config list 306 | ``` 307 | 308 | ### Clear the cache 309 | ``` 310 | $ glaball cache clean 311 | ``` 312 | 313 | ### Show the list of current versions 314 | ``` 315 | $ glaball versions 316 | ``` 317 | 318 | ### Show the active user 319 | ``` 320 | $ glaball whoami 321 | ``` 322 | 323 | # Community 324 | 325 | Originally created in [Flant](https://flant.com/). 326 | 327 | Please, feel free to reach developers/maintainers and users via [GitHub Discussions](https://github.com/flant/glaball/discussions) for any questions. 328 | 329 | You're welcome to follow [@flant_com](https://twitter.com/flant_com) to stay informed about all our Open Source initiatives. 330 | 331 | # License 332 | 333 | Apache License 2.0, see [LICENSE](LICENSE). 334 | 335 | GITLAB, the GitLab logo and all other GitLab trademarks herein are the registered and unregistered trademarks of [GitLab, Inc.](https://about.gitlab.com/) 336 | -------------------------------------------------------------------------------- /cmd/cache/cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/flant/glaball/cmd/common" 7 | 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func NewCmd() *cobra.Command { 12 | cmd := &cobra.Command{ 13 | Use: "cache", 14 | Short: "Cache management", 15 | } 16 | 17 | cmd.AddCommand( 18 | NewCleanCmd(), 19 | ) 20 | 21 | return cmd 22 | } 23 | 24 | func NewCleanCmd() *cobra.Command { 25 | cmd := &cobra.Command{ 26 | Use: "clean", 27 | Short: "Clean cache", 28 | RunE: func(cmd *cobra.Command, args []string) error { 29 | return Clean() 30 | }, 31 | } 32 | 33 | return cmd 34 | } 35 | 36 | func Clean() error { 37 | diskv, err := common.Config.Cache.Diskv() 38 | if err != nil { 39 | return err 40 | } 41 | if err := diskv.EraseAll(); err != nil { 42 | return err 43 | } 44 | 45 | fmt.Printf("Successfully cleaned up: %s\n", diskv.BasePath) 46 | 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /cmd/common/common.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "github.com/flant/glaball/pkg/client" 5 | "github.com/flant/glaball/pkg/config" 6 | "github.com/flant/glaball/pkg/limiter" 7 | 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | var ( 12 | Config *config.Config 13 | Client *client.Client 14 | Limiter *limiter.Limiter 15 | ) 16 | 17 | func Init() (err error) { 18 | var cfg config.Config 19 | if err = viper.Unmarshal(&cfg); err != nil { 20 | return err 21 | } 22 | 23 | Config = &cfg 24 | 25 | if Client, err = client.NewClient(Config); err != nil { 26 | return err 27 | } 28 | 29 | Limiter = limiter.NewLimiter(Config.Threads) 30 | 31 | return nil 32 | } 33 | -------------------------------------------------------------------------------- /cmd/common/test.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/flant/glaball/pkg/client" 9 | "github.com/flant/glaball/pkg/config" 10 | "github.com/flant/glaball/pkg/limiter" 11 | ) 12 | 13 | // Setup sets up a test HTTP server along with a gitlab.Client that is 14 | // configured to talk to that test server. Tests should register handlers on 15 | // mux which provide mock responses for the API method being tested. 16 | func Setup(t *testing.T) (*http.ServeMux, *httptest.Server, *client.Client) { 17 | // mux is the HTTP request multiplexer used with the test server. 18 | mux := http.NewServeMux() 19 | 20 | // server is a test HTTP server used to provide mock API responses. 21 | server := httptest.NewServer(mux) 22 | 23 | // client is the Gitlab client being tested. 24 | client, err := client.NewClient(&config.Config{ 25 | Hosts: map[string]map[string]map[string]config.Host{ 26 | "alfa": {"test": {"local": config.Host{URL: server.URL, Token: "testtoken"}}}, 27 | "beta": {"test": {"local": config.Host{URL: server.URL, Token: "testtoken"}}}, 28 | }, 29 | Cache: config.CacheOptions{}, 30 | Filter: "", 31 | Threads: limiter.DefaultLimit, 32 | }) 33 | 34 | if err != nil { 35 | server.Close() 36 | t.Fatalf("Failed to create client: %v", err) 37 | } 38 | 39 | return mux, server, client 40 | } 41 | 42 | // Teardown closes the test HTTP server. 43 | func Teardown(server *httptest.Server) { 44 | server.Close() 45 | } 46 | -------------------------------------------------------------------------------- /cmd/config/main.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "sort" 7 | "text/tabwriter" 8 | 9 | "github.com/flant/glaball/cmd/common" 10 | 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | func NewCmd() *cobra.Command { 15 | cmd := &cobra.Command{ 16 | Use: "config", 17 | Short: "Information about the current configuration", 18 | } 19 | 20 | cmd.AddCommand( 21 | NewListCmd(), 22 | ) 23 | 24 | return cmd 25 | } 26 | 27 | func NewListCmd() *cobra.Command { 28 | cmd := &cobra.Command{ 29 | Use: "list", 30 | Short: "List gitlabs stored in config", 31 | RunE: func(cmd *cobra.Command, args []string) error { 32 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', tabwriter.TabIndent) 33 | fmt.Fprintf(w, "HOST\tURL\n") 34 | total := 0 35 | 36 | sort.Sort(common.Client.Hosts) 37 | for _, h := range common.Client.Hosts { 38 | fmt.Fprintf(w, "[%s]\t%s\n", h.FullName(), h.URL) 39 | total++ 40 | } 41 | 42 | fmt.Fprintf(w, "Total: %d\n", total) 43 | 44 | w.Flush() 45 | 46 | return nil 47 | }, 48 | } 49 | 50 | return cmd 51 | } 52 | -------------------------------------------------------------------------------- /cmd/info/main.go: -------------------------------------------------------------------------------- 1 | package info 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/flant/glaball/pkg/config" 7 | "github.com/flant/glaball/pkg/util" 8 | 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | func NewCmd() *cobra.Command { 13 | cmd := &cobra.Command{ 14 | Use: "info", 15 | Short: "Information about the current build", 16 | Run: func(cmd *cobra.Command, args []string) { 17 | fmt.Println(util.PrintVersion(config.ApplicationName)) 18 | }, 19 | } 20 | 21 | return cmd 22 | } 23 | -------------------------------------------------------------------------------- /cmd/projects/branches.go: -------------------------------------------------------------------------------- 1 | package projects 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | "text/tabwriter" 8 | 9 | "github.com/flant/glaball/cmd/common" 10 | "github.com/flant/glaball/pkg/client" 11 | "github.com/flant/glaball/pkg/limiter" 12 | "github.com/flant/glaball/pkg/sort/v2" 13 | "github.com/flant/glaball/pkg/util" 14 | "github.com/google/go-github/v66/github" 15 | "github.com/hashicorp/go-hclog" 16 | "github.com/spf13/cobra" 17 | "github.com/xanzy/go-gitlab" 18 | ) 19 | 20 | const ( 21 | branchDefaultField = "project.web_url" 22 | ) 23 | 24 | var ( 25 | listBranchesOptions = gitlab.ListBranchesOptions{ListOptions: gitlab.ListOptions{PerPage: 100}} 26 | branchOrderBy []string 27 | branchFormat = util.Dict{ 28 | { 29 | Key: "HOST", 30 | Value: "[%s]", 31 | }, 32 | { 33 | Key: "URL", 34 | Value: "%s", 35 | }, 36 | { 37 | Key: "LAST UPDATED", 38 | Value: "[%s]", 39 | }, 40 | { 41 | Key: "CACHED", 42 | Value: "[%s]", 43 | }, 44 | } 45 | ) 46 | 47 | func NewBranchesCmd() *cobra.Command { 48 | cmd := &cobra.Command{ 49 | Use: "branches", 50 | Short: "Branches API", 51 | } 52 | 53 | cmd.AddCommand( 54 | NewBranchesListCmd(), 55 | NewProtectedBranchesCmd(), 56 | ) 57 | 58 | return cmd 59 | } 60 | 61 | func NewBranchesListCmd() *cobra.Command { 62 | cmd := &cobra.Command{ 63 | Use: "list", 64 | Short: "List repository branches", 65 | Long: "Get a list of repository branches from a project, sorted by name alphabetically.", 66 | RunE: func(cmd *cobra.Command, args []string) error { 67 | return BranchesListCmd() 68 | }, 69 | } 70 | 71 | cmd.Flags().Var(util.NewEnumValue(&sortBy, "asc", "desc"), "sort", 72 | "Return branches sorted in asc or desc order. Default is desc") 73 | 74 | cmd.Flags().StringSliceVar(&branchOrderBy, "order_by", []string{"count", branchDefaultField}, 75 | `Return branches ordered by web_url, created_at, title, updated_at or any nested field. Default is web_url.`) 76 | 77 | listProjectsOptionsFlags(cmd, &listProjectsOptions) 78 | 79 | return cmd 80 | } 81 | 82 | type ProjectBranch struct { 83 | Project *gitlab.Project `json:"project,omitempty"` 84 | Branches []*gitlab.Branch `json:"branches,omitempty"` 85 | } 86 | 87 | type RepositoryBranch struct { 88 | Repository *github.Repository `json:"repository,omitempty"` 89 | Branch *github.Branch `json:"branch,omitempty"` 90 | } 91 | 92 | func BranchesListCmd() error { 93 | if !sort.ValidOrderBy(branchOrderBy, ProjectBranch{}) { 94 | branchOrderBy = append(branchOrderBy, branchDefaultField) 95 | } 96 | 97 | wg := common.Limiter 98 | data := make(chan interface{}) 99 | defer func() { 100 | for _, err := range wg.Errors() { 101 | hclog.L().Error(err.Err.Error()) 102 | } 103 | }() 104 | 105 | for _, h := range common.Client.Hosts { 106 | fmt.Printf("Getting branches from %s ...\n", h.URL) 107 | wg.Add(1) 108 | go listProjects(h, listProjectsOptions, wg, data, common.Client.WithCache()) 109 | } 110 | 111 | go func() { 112 | wg.Wait() 113 | close(data) 114 | }() 115 | 116 | toList := make(sort.Elements, 0) 117 | for e := range data { 118 | toList = append(toList, e) 119 | } 120 | 121 | if len(toList) == 0 { 122 | return fmt.Errorf("no projects found") 123 | } 124 | 125 | branches := make(chan interface{}) 126 | for _, v := range toList.Typed() { 127 | wg.Add(1) 128 | go listBranches(v.Host, v.Struct.(*gitlab.Project), listBranchesOptions, wg, branches, common.Client.WithCache()) 129 | } 130 | 131 | go func() { 132 | wg.Wait() 133 | close(branches) 134 | }() 135 | 136 | results, err := sort.FromChannel(branches, &sort.Options{ 137 | OrderBy: branchOrderBy, 138 | SortBy: sortBy, 139 | GroupBy: branchDefaultField, 140 | StructType: ProjectBranch{}, 141 | }) 142 | if err != nil { 143 | return err 144 | } 145 | 146 | if len(results) == 0 { 147 | return fmt.Errorf("no branches found") 148 | } 149 | 150 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', tabwriter.TabIndent) 151 | if _, err := fmt.Fprintln(w, strings.Join(branchFormat.Keys(), "\t")); err != nil { 152 | return err 153 | } 154 | 155 | unique := 0 156 | total := 0 157 | 158 | for _, r := range results { 159 | unique++ 160 | total += r.Count 161 | for _, v := range r.Elements.Typed() { 162 | pb := v.Struct.(*ProjectBranch) 163 | for _, b := range pb.Branches { 164 | if err := branchFormat.Print(w, "\t", 165 | v.Host.ProjectName(), 166 | b.WebURL, 167 | b.Commit.CommittedDate.Format("2006-01-02 15:04:05"), 168 | v.Cached, 169 | ); err != nil { 170 | return err 171 | } 172 | } 173 | 174 | } 175 | 176 | } 177 | 178 | if err := totalFormat.Print(w, "\n", unique, total, len(wg.Errors())); err != nil { 179 | return err 180 | } 181 | 182 | if err := w.Flush(); err != nil { 183 | return err 184 | } 185 | 186 | return nil 187 | } 188 | 189 | func listBranches(h *client.Host, project *gitlab.Project, opt gitlab.ListBranchesOptions, 190 | wg *limiter.Limiter, data chan<- interface{}, options ...gitlab.RequestOptionFunc) error { 191 | 192 | defer wg.Done() 193 | 194 | wg.Lock() 195 | list, resp, err := h.Client.Branches.ListBranches(project.ID, &opt, options...) 196 | wg.Unlock() 197 | if err != nil { 198 | wg.Error(h, err) 199 | return err 200 | } 201 | 202 | data <- sort.Element{ 203 | Host: h, 204 | Struct: &ProjectBranch{Project: project, Branches: list}, 205 | Cached: resp.Header.Get("X-From-Cache") == "1"} 206 | 207 | if resp.NextPage > 0 { 208 | wg.Add(1) 209 | opt.Page = resp.NextPage 210 | go listBranches(h, project, opt, wg, data, options...) 211 | } 212 | 213 | return nil 214 | } 215 | -------------------------------------------------------------------------------- /cmd/projects/edit.go: -------------------------------------------------------------------------------- 1 | package projects 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "text/tabwriter" 7 | 8 | "github.com/flant/glaball/pkg/client" 9 | "github.com/flant/glaball/pkg/limiter" 10 | "github.com/flant/glaball/pkg/sort/v2" 11 | "github.com/flant/glaball/pkg/util" 12 | 13 | "github.com/flant/glaball/cmd/common" 14 | 15 | "github.com/hashicorp/go-hclog" 16 | "github.com/spf13/cobra" 17 | "github.com/xanzy/go-gitlab" 18 | ) 19 | 20 | var ( 21 | editProjectsOptions = gitlab.EditProjectOptions{} 22 | ) 23 | 24 | func NewEditCmd() *cobra.Command { 25 | cmd := &cobra.Command{ 26 | Use: "edit", 27 | Short: "Edit projects.", 28 | RunE: func(cmd *cobra.Command, args []string) error { 29 | return Edit() 30 | }, 31 | } 32 | 33 | cmd.Flags().Var(util.NewEnumValue(&groupBy, "name", "path"), "group_by", 34 | "Return projects grouped by id, name, path, fields.") 35 | 36 | cmd.Flags().Var(util.NewEnumValue(&sortBy, "asc", "desc"), "sort", 37 | "Return projects sorted in asc or desc order. Default is desc") 38 | 39 | cmd.Flags().StringSliceVar(&orderBy, "order_by", []string{"count", projectDefaultField}, 40 | `Return projects ordered by id, name, path, created_at, updated_at, last_activity_at, or similarity fields. 41 | repository_size, storage_size, packages_size or wiki_size fields are only allowed for administrators. 42 | similarity (introduced in GitLab 14.1) is only available when searching and is limited to projects that the current user is a member of.`) 43 | 44 | listProjectsOptionsFlags(cmd, &listProjectsOptions) 45 | editProjectsOptionsFlags(cmd, &editProjectsOptions) 46 | 47 | return cmd 48 | } 49 | 50 | func editProjectsOptionsFlags(cmd *cobra.Command, opt *gitlab.EditProjectOptions) { 51 | cmd.Flags().Var(util.NewStringPtrValue(&opt.AutoCancelPendingPipelines), "auto_cancel_pending_pipelines", 52 | "Auto-cancel pending pipelines. This isn’t a boolean, but enabled/disabled.") 53 | 54 | cmd.Flags().Var(util.NewIntPtrValue(&opt.CIDefaultGitDepth), "ci_default_git_depth", 55 | "Default number of revisions for shallow cloning.") 56 | 57 | cmd.Flags().Var(util.NewBoolPtrValue(&opt.CIForwardDeploymentEnabled), "ci_forward_deployment_enabled", 58 | "Enable or disable prevent outdated deployment jobs.") 59 | 60 | cmd.Flags().Var(util.NewStringPtrValue(&opt.DefaultBranch), "default_branch", 61 | "The default branch name.") 62 | 63 | cmd.Flags().Var(util.NewBoolPtrValue(&opt.SharedRunnersEnabled), "shared_runners_enabled", 64 | "Enable shared runners for this project.") 65 | } 66 | 67 | func Edit() error { 68 | if !sort.ValidOrderBy(orderBy, gitlab.Project{}) { 69 | orderBy = append(orderBy, projectDefaultField) 70 | } 71 | 72 | wg := common.Limiter 73 | data := make(chan interface{}) 74 | 75 | for _, h := range common.Client.Hosts { 76 | fmt.Printf("Fetching projects from %s ...\n", h.URL) 77 | // TODO: context with cancel 78 | wg.Add(1) 79 | go listProjects(h, listProjectsOptions, wg, data, common.Client.WithCache()) 80 | } 81 | 82 | go func() { 83 | wg.Wait() 84 | close(data) 85 | }() 86 | 87 | toList := make(sort.Elements, 0) 88 | for e := range data { 89 | toList = append(toList, e) 90 | } 91 | 92 | if len(toList) == 0 { 93 | return fmt.Errorf("no projects found") 94 | } 95 | 96 | projects := make(chan interface{}) 97 | for _, v := range toList.Typed() { 98 | wg.Add(1) 99 | go editProject(v.Host, v.Struct.(*gitlab.Project), editProjectsOptions, wg, projects, common.Client.WithCache()) 100 | } 101 | 102 | go func() { 103 | wg.Wait() 104 | close(projects) 105 | }() 106 | 107 | results, err := sort.FromChannel(projects, &sort.Options{ 108 | OrderBy: orderBy, 109 | SortBy: sortBy, 110 | GroupBy: groupBy, 111 | StructType: gitlab.Project{}, 112 | }) 113 | if err != nil { 114 | return err 115 | } 116 | 117 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', tabwriter.TabIndent) 118 | fmt.Fprintf(w, "COUNT\tREPOSITORY\tHOSTS\tCACHED\n") 119 | unique := 0 120 | total := 0 121 | 122 | for _, v := range results { 123 | unique++ // todo 124 | total += v.Count //todo 125 | fmt.Fprintf(w, "[%d]\t%s\t%s\t[%s]\n", v.Count, v.Key, v.Elements.Hosts().Projects(common.Config.ShowAll), v.Cached) 126 | } 127 | 128 | fmt.Fprintf(w, "Unique: %d\nTotal: %d\nErrors: %d\n", unique, total, len(wg.Errors())) 129 | 130 | w.Flush() 131 | 132 | for _, err := range wg.Errors() { 133 | hclog.L().Error(err.Err.Error()) 134 | } 135 | 136 | return nil 137 | } 138 | 139 | func editProject(h *client.Host, project *gitlab.Project, opt gitlab.EditProjectOptions, wg *limiter.Limiter, 140 | data chan<- interface{}, options ...gitlab.RequestOptionFunc) error { 141 | 142 | defer wg.Done() 143 | 144 | wg.Lock() 145 | 146 | v, resp, err := h.Client.Projects.EditProject(project.ID, &opt, options...) 147 | if err != nil { 148 | wg.Error(h, err) 149 | wg.Unlock() 150 | return err 151 | } 152 | 153 | wg.Unlock() 154 | 155 | data <- sort.Element{Host: h, Struct: v, Cached: resp.Header.Get("X-From-Cache") == "1"} 156 | 157 | return nil 158 | } 159 | -------------------------------------------------------------------------------- /cmd/projects/files.go: -------------------------------------------------------------------------------- 1 | package projects 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "fmt" 8 | "os" 9 | "regexp" 10 | "strings" 11 | "text/tabwriter" 12 | "time" 13 | 14 | "github.com/flant/glaball/pkg/client" 15 | "github.com/flant/glaball/pkg/limiter" 16 | "github.com/flant/glaball/pkg/sort/v2" 17 | "github.com/google/go-github/v66/github" 18 | "gopkg.in/yaml.v3" 19 | 20 | "github.com/flant/glaball/cmd/common" 21 | 22 | "github.com/hashicorp/go-hclog" 23 | "github.com/spf13/cobra" 24 | "github.com/xanzy/go-gitlab" 25 | ) 26 | 27 | var ( 28 | listProjectsFilesOptions = gitlab.ListProjectsOptions{ListOptions: gitlab.ListOptions{PerPage: 100}} 29 | 30 | filepaths []string 31 | patterns []string 32 | 33 | gitRef string 34 | showContents bool 35 | showNumOfLines int 36 | ) 37 | 38 | func NewFilesCmd() *cobra.Command { 39 | cmd := &cobra.Command{ 40 | Use: "files", 41 | Short: "Repository files", 42 | } 43 | cmd.AddCommand( 44 | NewSearchCmd(), 45 | ) 46 | 47 | return cmd 48 | } 49 | 50 | func NewSearchCmd() *cobra.Command { 51 | cmd := &cobra.Command{ 52 | Use: "search", 53 | Short: "Search repository files content", 54 | RunE: func(cmd *cobra.Command, args []string) error { 55 | return Search() 56 | }, 57 | } 58 | 59 | cmd.Flags().StringSliceVar(&filepaths, "filepath", []string{}, "List of project files to search for pattern") 60 | cmd.MarkFlagRequired("filepath") 61 | 62 | cmd.Flags().StringSliceVar(&patterns, "pattern", []string{".*"}, "List of regex patterns to search in files") 63 | cmd.Flags().StringVar(&gitRef, "ref", "", "Git branch to search file in. Default branch if no value provided") 64 | cmd.Flags().BoolVar(&showContents, "show", false, "Show the contents of the file you are looking for") 65 | cmd.Flags().IntVar(&showNumOfLines, "num", 0, "Number of lines of file contents to show") 66 | 67 | // ListProjectsOptions 68 | listProjectsOptionsFlags(cmd, &listProjectsFilesOptions) 69 | 70 | return cmd 71 | } 72 | 73 | func Search() error { 74 | re := make([]*regexp.Regexp, 0, len(patterns)) 75 | for _, p := range patterns { 76 | r, err := regexp.Compile(p) 77 | if err != nil { 78 | return err 79 | } 80 | re = append(re, r) 81 | } 82 | 83 | wg := common.Limiter 84 | data := make(chan interface{}) 85 | 86 | for _, h := range common.Client.Hosts { 87 | fmt.Printf("Searching for files in %s ...\n", h.URL) 88 | // TODO: context with cancel 89 | for _, fp := range filepaths { 90 | wg.Add(1) 91 | go listProjectsFiles(h, fp, gitRef, re, listProjectsFilesOptions, wg, data, common.Client.WithCache()) 92 | } 93 | } 94 | 95 | go func() { 96 | wg.Wait() 97 | close(data) 98 | }() 99 | 100 | results, err := sort.FromChannel(data, &sort.Options{ 101 | OrderBy: []string{"project.web_url"}, 102 | SortBy: "desc", 103 | GroupBy: "", 104 | StructType: ProjectFile{}, 105 | }) 106 | if err != nil { 107 | return err 108 | } 109 | 110 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', tabwriter.TabIndent) 111 | fmt.Fprintf(w, "COUNT\tREPOSITORY\tHOSTS\tCACHED\n") 112 | unique := 0 113 | total := 0 114 | 115 | for _, v := range results { 116 | unique++ // todo 117 | total += v.Count //todo 118 | fmt.Fprintf(w, "[%d]\t%s\t%s\t[%s]\n", v.Count, v.Key, v.Elements.Hosts().Projects(common.Config.ShowAll), v.Cached) 119 | if showContents { 120 | for _, e := range v.Elements.Typed() { 121 | if showNumOfLines > 0 { 122 | r := bytes.NewReader(e.Struct.(*ProjectFile).Raw) 123 | scanner := bufio.NewScanner(r) 124 | for i := 0; i < showNumOfLines; i++ { 125 | if scanner.Scan() { 126 | fmt.Fprintf(w, "%s\n", scanner.Text()) 127 | } 128 | } 129 | fmt.Fprint(w, "\n") 130 | } else { 131 | fmt.Fprintf(w, "%s\n", e.Struct.(*ProjectFile).Raw) 132 | } 133 | } 134 | } 135 | } 136 | 137 | fmt.Fprintf(w, "Unique: %d\nTotal: %d\nErrors: %d\n", unique, total, len(wg.Errors())) 138 | 139 | for _, err := range wg.Errors() { 140 | hclog.L().Error(err.Err.Error()) 141 | } 142 | 143 | w.Flush() 144 | 145 | return nil 146 | } 147 | 148 | func SearchRegexp() error { 149 | // do not allow to list project's tree for more than 1 host 150 | if len(common.Client.Hosts) > 1 { 151 | return fmt.Errorf("you don't want to use it as bulk function") 152 | } 153 | 154 | re := make([]*regexp.Regexp, 0, len(patterns)) 155 | for _, p := range patterns { 156 | r, err := regexp.Compile(p) 157 | if err != nil { 158 | return err 159 | } 160 | re = append(re, r) 161 | } 162 | 163 | wg := common.Limiter 164 | data := make(chan interface{}) 165 | 166 | for _, h := range common.Client.Hosts { 167 | fmt.Printf("Searching for files in %s ...\n", h.URL) 168 | wg.Add(1) 169 | go listProjectsFilesRegexp(h, gitRef, re, listProjectsFilesOptions, wg, data, common.Client.WithCache()) 170 | } 171 | 172 | go func() { 173 | wg.Wait() 174 | close(data) 175 | }() 176 | 177 | results, err := sort.FromChannel(data, &sort.Options{ 178 | OrderBy: []string{"web_url"}, 179 | SortBy: "desc", 180 | GroupBy: "", 181 | StructType: gitlab.Project{}, 182 | }) 183 | if err != nil { 184 | return err 185 | } 186 | 187 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', tabwriter.TabIndent) 188 | fmt.Fprintf(w, "COUNT\tREPOSITORY\tHOSTS\tCACHED\n") 189 | unique := 0 190 | total := 0 191 | 192 | for _, v := range results { 193 | unique++ // todo 194 | total += v.Count //todo 195 | fmt.Fprintf(w, "[%d]\t%s\t%s\t[%s]\n", v.Count, v.Key, v.Elements.Hosts().Projects(common.Config.ShowAll), v.Cached) 196 | } 197 | 198 | fmt.Fprintf(w, "Unique: %d\nTotal: %d\nErrors: %d\n", unique, total, len(wg.Errors())) 199 | 200 | w.Flush() 201 | 202 | for _, err := range wg.Errors() { 203 | hclog.L().Error(err.Err.Error()) 204 | } 205 | 206 | return nil 207 | } 208 | 209 | func listProjectsFiles(h *client.Host, filepath, ref string, re []*regexp.Regexp, opt gitlab.ListProjectsOptions, 210 | wg *limiter.Limiter, data chan<- interface{}, options ...gitlab.RequestOptionFunc) { 211 | 212 | defer wg.Done() 213 | 214 | wg.Lock() 215 | list, resp, err := h.Client.Projects.ListProjects(&opt, options...) 216 | if err != nil { 217 | wg.Error(h, err) 218 | wg.Unlock() 219 | return 220 | } 221 | wg.Unlock() 222 | 223 | for _, v := range list { 224 | wg.Add(1) 225 | // TODO: handle deadlock when no files found 226 | go getRawFile(h, v, filepath, ref, re, wg, data, options...) 227 | } 228 | 229 | if resp.NextPage > 0 { 230 | wg.Add(1) 231 | opt.Page = resp.NextPage 232 | go listProjectsFiles(h, filepath, ref, re, opt, wg, data, options...) 233 | } 234 | } 235 | 236 | func listProjectsFilesFromGithub(h *client.Host, filepath, ref string, re []*regexp.Regexp, opt github.RepositoryListByOrgOptions, 237 | wg *limiter.Limiter, data chan<- interface{}, options ...gitlab.RequestOptionFunc) { 238 | 239 | defer wg.Done() 240 | 241 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) 242 | defer cancel() 243 | wg.Lock() 244 | list, resp, err := h.GithubClient.Repositories.ListByOrg(context.WithValue(ctx, github.SleepUntilPrimaryRateLimitResetWhenRateLimited, true), h.Org, &opt) 245 | if err != nil { 246 | if err != nil { 247 | wg.Error(h, err) 248 | wg.Unlock() 249 | return 250 | } 251 | } 252 | wg.Unlock() 253 | 254 | for _, v := range list { 255 | wg.Add(1) 256 | // TODO: handle deadlock when no files found 257 | go getRawFileFromGithub(h, v, filepath, ref, re, wg, data) 258 | } 259 | 260 | if resp.NextPage > 0 { 261 | wg.Add(1) 262 | opt.Page = resp.NextPage 263 | go listProjectsFilesFromGithub(h, filepath, ref, re, opt, wg, data, options...) 264 | } 265 | 266 | return 267 | } 268 | 269 | func getRawFile(h *client.Host, project *gitlab.Project, filepath, ref string, re []*regexp.Regexp, 270 | wg *limiter.Limiter, data chan<- interface{}, options ...gitlab.RequestOptionFunc) { 271 | 272 | defer wg.Done() 273 | 274 | targetRef := ref 275 | if ref == "" { 276 | targetRef = project.DefaultBranch 277 | } 278 | wg.Lock() 279 | raw, resp, err := h.Client.RepositoryFiles.GetRawFile(project.ID, filepath, &gitlab.GetRawFileOptions{Ref: &targetRef}, options...) 280 | wg.Unlock() 281 | if err != nil { 282 | hclog.L().Named("files").Trace("get raw file error", "project", project.WebURL, "error", err) 283 | return 284 | } 285 | 286 | for _, r := range re { 287 | if r.Match(raw) { 288 | data <- sort.Element{Host: h, Struct: &ProjectFile{Project: project, Raw: raw}, Cached: resp.Header.Get("X-From-Cache") == "1"} 289 | hclog.L().Named("files").Trace("search pattern was found in file", "team", h.Team, "project", h.Project, "host", h.URL, 290 | "repo", project.WebURL, "file", filepath, "pattern", r.String(), "content", hclog.Fmt("%s", raw)) 291 | return 292 | } 293 | } 294 | } 295 | 296 | // TODO: 297 | func getRawFileFromGithub(h *client.Host, repository *github.Repository, filepath, ref string, re []*regexp.Regexp, 298 | wg *limiter.Limiter, data chan<- interface{}) { 299 | 300 | defer wg.Done() 301 | 302 | targetRef := ref 303 | if ref == "" { 304 | targetRef = repository.GetDefaultBranch() 305 | } 306 | // TODO: 307 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) 308 | defer cancel() 309 | wg.Lock() 310 | fileContent, _, resp, err := h.GithubClient.Repositories.GetContents(context.WithValue(ctx, github.SleepUntilPrimaryRateLimitResetWhenRateLimited, true), 311 | repository.Owner.GetLogin(), 312 | repository.GetName(), 313 | filepath, 314 | &github.RepositoryContentGetOptions{Ref: targetRef}) 315 | wg.Unlock() 316 | if err != nil { 317 | hclog.L().Named("files").Trace("get raw file error", "repository", repository.GetHTMLURL(), "error", err) 318 | return 319 | } 320 | 321 | raw, err := fileContent.GetContent() 322 | if err != nil { 323 | hclog.L().Named("files").Trace("get raw file error", "repository", repository.GetHTMLURL(), "error", err) 324 | return 325 | } 326 | 327 | for _, r := range re { 328 | if r.MatchString(raw) { 329 | data <- sort.Element{Host: h, Struct: &RepositoryFile{Repository: repository, Raw: raw}, Cached: resp.Header.Get("X-From-Cache") == "1"} 330 | hclog.L().Named("files").Trace("search pattern was found in file", "team", h.Team, "repository", h.Project, "host", h.URL, 331 | "repo", repository.GetHTMLURL(), "file", filepath, "pattern", r.String(), "content", hclog.Fmt("%s", raw)) 332 | return 333 | } 334 | } 335 | } 336 | 337 | func getGitlabCIFile(h *client.Host, check bool, project *gitlab.Project, re []*regexp.Regexp, 338 | wg *limiter.Limiter, data chan<- interface{}, options ...gitlab.RequestOptionFunc) { 339 | 340 | defer wg.Done() 341 | 342 | wg.Lock() 343 | lint, resp, err := h.Client.Validate.ProjectLint(project.ID, &gitlab.ProjectLintOptions{}, options...) 344 | wg.Unlock() 345 | if err != nil { 346 | hclog.L().Named("files").Trace("project lint error", "project", project.WebURL, "error", err) 347 | return 348 | } 349 | 350 | var v map[string]interface{} 351 | if err := yaml.NewDecoder(strings.NewReader(lint.MergedYaml)).Decode(&v); err != nil { 352 | hclog.L().Named("files").Debug("error decoding .gitlab-ci.yml file, skipping", "team", h.Team, "project", h.Project, "host", h.URL, 353 | "repo", project.WebURL, "content", lint.MergedYaml, "error", err) 354 | return 355 | } 356 | 357 | if !check { 358 | data <- sort.Element{Host: h, Struct: &ProjectLintResult{Project: project, MergedYaml: v}, Cached: resp.Header.Get("X-From-Cache") == "1"} 359 | return 360 | } 361 | 362 | for _, r := range re { 363 | if r.MatchString(lint.MergedYaml) { 364 | data <- sort.Element{Host: h, Struct: &ProjectLintResult{Project: project, MergedYaml: v}, Cached: resp.Header.Get("X-From-Cache") == "1"} 365 | hclog.L().Named("files").Trace("search pattern was found in file", "team", h.Team, "project", h.Project, "host", h.URL, 366 | "repo", project.WebURL, "pattern", r.String(), "content", lint.MergedYaml) 367 | return 368 | } 369 | } 370 | 371 | hclog.L().Named("files").Debug("search pattern was not found in file", "team", h.Team, "project", h.Project, "host", h.URL, 372 | "repo", project.WebURL, "patterns", hclog.Fmt("%v", re)) 373 | } 374 | 375 | type ProjectFile struct { 376 | Project *gitlab.Project `json:"project,omitempty"` 377 | Raw []byte 378 | } 379 | 380 | type RepositoryFile struct { 381 | Repository *github.Repository `json:"repository,omitempty"` 382 | Raw string 383 | } 384 | 385 | type ProjectLintResult struct { 386 | Project *gitlab.Project `json:"project,omitempty"` 387 | MergedYaml map[string]interface{} `json:"merged_yaml,omitempty"` 388 | } 389 | 390 | func listProjectsFilesRegexp(h *client.Host, ref string, re []*regexp.Regexp, opt gitlab.ListProjectsOptions, 391 | wg *limiter.Limiter, data chan<- interface{}, options ...gitlab.RequestOptionFunc) { 392 | 393 | defer wg.Done() 394 | 395 | wg.Lock() 396 | list, resp, err := h.Client.Projects.ListProjects(&opt, options...) 397 | if err != nil { 398 | wg.Error(h, err) 399 | wg.Unlock() 400 | return 401 | } 402 | wg.Unlock() 403 | 404 | for _, v := range list { 405 | // context 406 | wg.Add(1) 407 | targetRef := ref 408 | if ref == "" { 409 | targetRef = v.DefaultBranch 410 | } 411 | go listTree(h, v, re, gitlab.ListTreeOptions{ListOptions: opt.ListOptions, Ref: &targetRef, Recursive: gitlab.Bool(true)}, 412 | wg, data, options...) 413 | } 414 | 415 | if resp.NextPage > 0 { 416 | wg.Add(1) 417 | opt.Page = resp.NextPage 418 | go listProjectsFilesRegexp(h, ref, re, opt, wg, data, options...) 419 | } 420 | } 421 | 422 | func listTree(h *client.Host, project *gitlab.Project, re []*regexp.Regexp, opt gitlab.ListTreeOptions, 423 | wg *limiter.Limiter, data chan<- interface{}, options ...gitlab.RequestOptionFunc) { 424 | 425 | defer wg.Done() 426 | 427 | wg.Lock() 428 | list, resp, err := h.Client.Repositories.ListTree(project.ID, &opt, options...) 429 | if err != nil { 430 | wg.Unlock() 431 | return 432 | } 433 | wg.Unlock() 434 | 435 | for _, v := range list { 436 | if v.Type == "blob" { 437 | for _, r := range re { 438 | if r.MatchString(v.Path) { 439 | wg.Add(1) 440 | go rawBlobContent(h, project, re, v.ID, wg, data, options...) 441 | return 442 | } 443 | } 444 | } 445 | } 446 | 447 | if resp.NextPage > 0 { 448 | wg.Add(1) 449 | opt.Page = resp.NextPage 450 | go listTree(h, project, re, opt, wg, data, options...) 451 | } 452 | 453 | } 454 | 455 | func rawBlobContent(h *client.Host, project *gitlab.Project, re []*regexp.Regexp, sha string, 456 | wg *limiter.Limiter, data chan<- interface{}, options ...gitlab.RequestOptionFunc) { 457 | 458 | defer wg.Done() 459 | 460 | wg.Lock() 461 | raw, resp, err := h.Client.Repositories.RawBlobContent(project.ID, sha, options...) 462 | if err != nil { 463 | wg.Error(h, err) 464 | wg.Unlock() 465 | return 466 | } 467 | wg.Unlock() 468 | 469 | for _, r := range re { 470 | if r.Match(raw) { 471 | data <- sort.Element{Host: h, Struct: project, Cached: resp.Header.Get("X-From-Cache") == "1"} 472 | return 473 | } 474 | } 475 | 476 | } 477 | 478 | func ListProjectsFiles(h *client.Host, filepath, ref string, re []*regexp.Regexp, opt gitlab.ListProjectsOptions, 479 | wg *limiter.Limiter, data chan<- interface{}, options ...gitlab.RequestOptionFunc) { 480 | listProjectsFiles(h, filepath, ref, re, opt, wg, data, options...) 481 | } 482 | 483 | func ListProjectsFilesFromGithub(h *client.Host, filepath, ref string, re []*regexp.Regexp, opt github.RepositoryListByOrgOptions, 484 | wg *limiter.Limiter, data chan<- interface{}, options ...gitlab.RequestOptionFunc) { 485 | listProjectsFilesFromGithub(h, filepath, ref, re, opt, wg, data, options...) 486 | } 487 | 488 | func GetRawFile(h *client.Host, project *gitlab.Project, filepath, ref string, re []*regexp.Regexp, 489 | wg *limiter.Limiter, data chan<- interface{}, options ...gitlab.RequestOptionFunc) { 490 | getRawFile(h, project, filepath, ref, re, wg, data, options...) 491 | } 492 | 493 | func GetRawFileFromGithub(h *client.Host, repository *github.Repository, filepath, ref string, re []*regexp.Regexp, 494 | wg *limiter.Limiter, data chan<- interface{}) { 495 | getRawFileFromGithub(h, repository, filepath, ref, re, wg, data) 496 | } 497 | -------------------------------------------------------------------------------- /cmd/projects/list.go: -------------------------------------------------------------------------------- 1 | package projects 2 | 3 | import ( 4 | "context" 5 | "encoding/csv" 6 | "fmt" 7 | "os" 8 | go_sort "sort" 9 | "strings" 10 | "text/tabwriter" 11 | "time" 12 | 13 | "github.com/flant/glaball/pkg/client" 14 | "github.com/flant/glaball/pkg/limiter" 15 | "github.com/flant/glaball/pkg/sort/v2" 16 | "github.com/flant/glaball/pkg/util" 17 | "github.com/google/go-github/v66/github" 18 | 19 | "github.com/flant/glaball/cmd/common" 20 | 21 | "github.com/hashicorp/go-hclog" 22 | "github.com/spf13/cobra" 23 | "github.com/xanzy/go-gitlab" 24 | ) 25 | 26 | var ( 27 | listProjectsOptions = gitlab.ListProjectsOptions{ListOptions: gitlab.ListOptions{PerPage: 100}} 28 | groupBy, sortBy string 29 | orderBy []string 30 | ) 31 | 32 | func NewListCmd() *cobra.Command { 33 | cmd := &cobra.Command{ 34 | Use: "list", 35 | Short: "List projects.", 36 | RunE: func(cmd *cobra.Command, args []string) error { 37 | if len(orderBy) == 0 { 38 | orderBy = []string{"count", projectDefaultField} 39 | } 40 | return List() 41 | }, 42 | } 43 | 44 | cmd.Flags().Var(util.NewEnumValue(&groupBy, "name", "path"), "group_by", 45 | "Return projects grouped by id, name, path, fields.") 46 | 47 | cmd.Flags().Var(util.NewEnumValue(&sortBy, "asc", "desc"), "sort", 48 | "Return projects sorted in asc or desc order. Default is desc") 49 | 50 | cmd.Flags().StringSliceVar(&orderBy, "order_by", []string{}, 51 | `Return projects ordered by id, name, path, created_at, updated_at, last_activity_at, or similarity fields. 52 | repository_size, storage_size, packages_size or wiki_size fields are only allowed for administrators. 53 | similarity (introduced in GitLab 14.1) is only available when searching and is limited to projects that the current user is a member of.`) 54 | 55 | listProjectsOptionsFlags(cmd, &listProjectsOptions) 56 | 57 | return cmd 58 | } 59 | 60 | func NewLanguagesCmd() *cobra.Command { 61 | cmd := &cobra.Command{ 62 | Use: "languages", 63 | Short: "List projects with languages.", 64 | RunE: func(cmd *cobra.Command, args []string) error { 65 | if len(orderBy) == 0 { 66 | orderBy = []string{"count", projectWithLanguagesDefaultField} 67 | } 68 | return ListWithLanguages() 69 | }, 70 | } 71 | 72 | cmd.Flags().Var(util.NewEnumValue(&groupBy, "name", "path"), "group_by", 73 | "Return projects grouped by id, name, path, fields.") 74 | 75 | cmd.Flags().Var(util.NewEnumValue(&sortBy, "asc", "desc"), "sort", 76 | "Return projects sorted in asc or desc order. Default is desc") 77 | 78 | cmd.Flags().StringSliceVar(&orderBy, "order_by", []string{}, 79 | `Return projects ordered by id, name, path, created_at, updated_at, last_activity_at, or similarity fields. 80 | repository_size, storage_size, packages_size or wiki_size fields are only allowed for administrators. 81 | similarity (introduced in GitLab 14.1) is only available when searching and is limited to projects that the current user is a member of.`) 82 | 83 | listProjectsOptionsFlags(cmd, &listProjectsOptions) 84 | 85 | return cmd 86 | } 87 | 88 | func listProjectsOptionsFlags(cmd *cobra.Command, opt *gitlab.ListProjectsOptions) { 89 | cmd.Flags().Var(util.NewBoolPtrValue(&opt.Archived), "archived", 90 | "Limit by archived status. (--archived or --no-archived). Default nil") 91 | 92 | cmd.Flags().Var(util.NewIntPtrValue(&opt.IDAfter), "id_after", 93 | "Limit results to projects with IDs greater than the specified ID.") 94 | 95 | cmd.Flags().Var(util.NewIntPtrValue(&opt.IDBefore), "id_before", 96 | "Limit results to projects with IDs less than the specified ID.") 97 | 98 | cmd.Flags().Var(util.NewTimePtrValue(&opt.LastActivityAfter), "last_activity_after", 99 | "Limit results to projects with last_activity after specified time. Format: ISO 8601 (YYYY-MM_DDTHH:MM:SSZ)") 100 | 101 | cmd.Flags().Var(util.NewTimePtrValue(&opt.LastActivityBefore), "last_activity_before", 102 | "Limit results to projects with last_activity before specified time. Format: ISO 8601 (YYYY-MM_DDTHH:MM:SSZ)") 103 | 104 | cmd.Flags().Var(util.NewBoolPtrValue(&opt.Membership), "membership", 105 | "Limit by projects that the current user is a member of.") 106 | 107 | cmd.Flags().Var(util.NewBoolPtrValue(&opt.Owned), "owned", 108 | "Limit by projects explicitly owned by the current user.") 109 | 110 | cmd.Flags().Var(util.NewBoolPtrValue(&opt.RepositoryChecksumFailed), "repository_checksum_failed", 111 | `Limit projects where the repository checksum calculation has failed (Introduced in GitLab 11.2). 112 | Available in GitLab Premium self-managed, GitLab Premium SaaS, and higher tiers.`) 113 | 114 | cmd.Flags().Var(util.NewStringPtrValue(&opt.Search), "search", 115 | "Return list of projects matching the search criteria.") 116 | 117 | cmd.Flags().Var(util.NewBoolPtrValue(&opt.SearchNamespaces), "search_namespaces", 118 | "Include ancestor namespaces when matching search criteria. Default is false.") 119 | 120 | cmd.Flags().Var(util.NewBoolPtrValue(&opt.Simple), "simple", 121 | `Return only limited fields for each project. 122 | This is a no-op without authentication as then only simple fields are returned.`) 123 | 124 | cmd.Flags().Var(util.NewBoolPtrValue(&opt.Starred), "starred", 125 | "Limit by projects starred by the current user.") 126 | 127 | cmd.Flags().Var(util.NewBoolPtrValue(&opt.Statistics), "statistics", 128 | "Include project statistics. Only available to Reporter or higher level role members.") 129 | 130 | cmd.Flags().Var(util.NewStringPtrValue(&opt.Topic), "topic", 131 | "Comma-separated topic names. Limit results to projects that match all of given topics. See topics attribute.") 132 | 133 | cmd.Flags().Var(util.NewVisibilityPtrValue(&opt.Visibility), "visibility", 134 | "Limit by visibility public, internal, or private.") 135 | 136 | cmd.Flags().Var(util.NewBoolPtrValue(&opt.WikiChecksumFailed), "wiki_checksum_failed", 137 | `Limit projects where the wiki checksum calculation has failed (Introduced in GitLab 11.2). 138 | Available in GitLab Premium self-managed, GitLab Premium SaaS, and higher tiers.`) 139 | 140 | cmd.Flags().Var(util.NewBoolPtrValue(&opt.WithCustomAttributes), "with_custom_attributes", 141 | "Include custom attributes in response. (administrator only)") 142 | 143 | cmd.Flags().Var(util.NewBoolPtrValue(&opt.WithIssuesEnabled), "with_issues_enabled", 144 | "Limit by enabled issues feature.") 145 | 146 | cmd.Flags().Var(util.NewBoolPtrValue(&opt.WithMergeRequestsEnabled), "with_merge_requests_enabled", 147 | "Limit by enabled merge requests feature.") 148 | 149 | cmd.Flags().Var(util.NewStringPtrValue(&opt.WithProgrammingLanguage), "with_programming_language", 150 | "Limit by projects which use the given programming language.") 151 | } 152 | 153 | func List() error { 154 | if !sort.ValidOrderBy(orderBy, gitlab.Project{}) { 155 | orderBy = append(orderBy, projectDefaultField) 156 | } 157 | 158 | wg := common.Limiter 159 | data := make(chan interface{}) 160 | 161 | for _, h := range common.Client.Hosts { 162 | fmt.Printf("Fetching projects from %s ...\n", h.URL) 163 | // TODO: context with cancel 164 | wg.Add(1) 165 | go listProjects(h, listProjectsOptions, wg, data, common.Client.WithCache()) 166 | } 167 | 168 | go func() { 169 | wg.Wait() 170 | close(data) 171 | }() 172 | 173 | results, err := sort.FromChannel(data, &sort.Options{ 174 | OrderBy: orderBy, 175 | SortBy: sortBy, 176 | GroupBy: groupBy, 177 | StructType: gitlab.Project{}, 178 | }) 179 | if err != nil { 180 | return err 181 | } 182 | 183 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', tabwriter.TabIndent) 184 | fmt.Fprintf(w, "COUNT\tREPOSITORY\tHOSTS\tCACHED\n") 185 | unique := 0 186 | total := 0 187 | 188 | for _, v := range results { 189 | unique++ // todo 190 | total += v.Count //todo 191 | fmt.Fprintf(w, "[%d]\t%s\t%s\t[%s]\n", v.Count, v.Key, v.Elements.Hosts().Projects(common.Config.ShowAll), v.Cached) 192 | } 193 | 194 | fmt.Fprintf(w, "Unique: %d\nTotal: %d\nErrors: %d\n", unique, total, len(wg.Errors())) 195 | 196 | w.Flush() 197 | 198 | for _, err := range wg.Errors() { 199 | hclog.L().Error(err.Err.Error()) 200 | } 201 | 202 | return nil 203 | } 204 | 205 | func ListWithLanguages() error { 206 | structT := new(ProjectWithLanguages) 207 | if !sort.ValidOrderBy(orderBy, structT) { 208 | orderBy = append(orderBy, projectWithLanguagesDefaultField) 209 | } 210 | 211 | wg := common.Limiter 212 | data := make(chan interface{}) 213 | 214 | for _, h := range common.Client.Hosts { 215 | fmt.Printf("Fetching projects from %s ...\n", h.URL) 216 | wg.Add(1) 217 | go listProjects(h, listProjectsOptions, wg, data, common.Client.WithCache()) 218 | } 219 | 220 | go func() { 221 | wg.Wait() 222 | close(data) 223 | }() 224 | 225 | projectList := make(sort.Elements, 0) 226 | for e := range data { 227 | projectList = append(projectList, e) 228 | } 229 | 230 | if len(projectList) == 0 { 231 | return fmt.Errorf("no projects found") 232 | } 233 | 234 | projectsWithLanguages := make(chan interface{}) 235 | for _, v := range projectList.Typed() { 236 | wg.Add(1) 237 | go getProjectLanguages(v.Host, v.Struct.(*gitlab.Project), wg, projectsWithLanguages, common.Client.WithCache()) 238 | } 239 | 240 | go func() { 241 | wg.Wait() 242 | close(projectsWithLanguages) 243 | }() 244 | 245 | results, err := sort.FromChannel(projectsWithLanguages, &sort.Options{ 246 | OrderBy: orderBy, 247 | SortBy: sortBy, 248 | GroupBy: groupBy, 249 | StructType: structT, 250 | }) 251 | if err != nil { 252 | return err 253 | } 254 | 255 | projectsWithLanguagesFormat := util.Dict{ 256 | { 257 | Key: "COUNT", 258 | Value: "[%d]", 259 | }, 260 | { 261 | Key: "REPOSITORY", 262 | Value: "%s", 263 | }, 264 | { 265 | Key: "LANGUAGES", 266 | Value: "[%s]", 267 | }, 268 | { 269 | Key: "HOST", 270 | Value: "[%s]", 271 | }, 272 | { 273 | Key: "CACHED", 274 | Value: "[%s]", 275 | }, 276 | } 277 | 278 | if util.ContainsString(outputFormat, "csv") { 279 | w := csv.NewWriter(os.Stdout) 280 | w.Write([]string{"HOST", "REPOSITORY", "LANGUAGES"}) 281 | for _, r := range results { 282 | for _, v := range r.Elements.Typed() { 283 | p, ok := v.Struct.(*ProjectWithLanguages) 284 | if !ok { 285 | return fmt.Errorf("unexpected data type: %#v", v.Struct) 286 | } 287 | if err := w.Write([]string{ 288 | v.Host.Project, 289 | r.Key, 290 | p.LanguagesToString(), 291 | }); err != nil { 292 | return err 293 | } 294 | } 295 | } 296 | w.Flush() 297 | 298 | } 299 | 300 | if util.ContainsString(outputFormat, "table") { 301 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', tabwriter.TabIndent) 302 | if _, err := fmt.Fprintln(w, strings.Join(projectsWithLanguagesFormat.Keys(), "\t")); err != nil { 303 | return err 304 | } 305 | unique := 0 306 | total := 0 307 | 308 | for _, r := range results { 309 | unique++ // todo 310 | total += r.Count //todo 311 | 312 | for _, v := range r.Elements.Typed() { 313 | p, ok := v.Struct.(*ProjectWithLanguages) 314 | if !ok { 315 | return fmt.Errorf("unexpected data type: %#v", v.Struct) 316 | } 317 | 318 | if err := projectsWithLanguagesFormat.Print(w, "\t", 319 | r.Count, 320 | r.Key, 321 | p.LanguagesToString(), 322 | v.Host.ProjectName(), 323 | r.Cached, 324 | ); err != nil { 325 | return err 326 | } 327 | } 328 | } 329 | 330 | if err := totalFormat.Print(w, "\n", unique, total, len(wg.Errors())); err != nil { 331 | return err 332 | } 333 | 334 | if err := w.Flush(); err != nil { 335 | return err 336 | } 337 | } 338 | 339 | for _, err := range wg.Errors() { 340 | hclog.L().Error(err.Err.Error()) 341 | } 342 | 343 | return nil 344 | } 345 | 346 | func listProjects(h *client.Host, opt gitlab.ListProjectsOptions, wg *limiter.Limiter, data chan<- interface{}, 347 | options ...gitlab.RequestOptionFunc) error { 348 | 349 | defer wg.Done() 350 | 351 | // TODO: 352 | if h.GithubClient != nil { 353 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) 354 | defer cancel() 355 | list, resp, err := h.GithubClient.Repositories.ListByOrg(context.WithValue(ctx, github.SleepUntilPrimaryRateLimitResetWhenRateLimited, true), h.Org, 356 | &github.RepositoryListByOrgOptions{ListOptions: github.ListOptions{PerPage: 100}}, 357 | ) 358 | if err != nil { 359 | wg.Error(h, err) 360 | return err 361 | } 362 | 363 | for _, v := range list { 364 | data <- sort.Element{Host: h, Struct: v, Cached: resp.Header.Get("X-From-Cache") == "1"} 365 | } 366 | 367 | if resp.NextPage > 0 { 368 | wg.Add(1) 369 | opt.Page = resp.NextPage 370 | go listProjects(h, opt, wg, data, options...) 371 | } 372 | 373 | return nil 374 | 375 | } 376 | 377 | wg.Lock() 378 | 379 | list, resp, err := h.Client.Projects.ListProjects(&opt, options...) 380 | if err != nil { 381 | wg.Error(h, err) 382 | wg.Unlock() 383 | return err 384 | } 385 | 386 | wg.Unlock() // TODO: ratelimiter 387 | 388 | for _, v := range list { 389 | data <- sort.Element{Host: h, Struct: v, Cached: resp.Header.Get("X-From-Cache") == "1"} 390 | } 391 | 392 | if resp.NextPage > 0 { 393 | wg.Add(1) 394 | opt.Page = resp.NextPage 395 | go listProjects(h, opt, wg, data, options...) 396 | } 397 | 398 | return nil 399 | } 400 | 401 | func getProjectLanguages(h *client.Host, project *gitlab.Project, wg *limiter.Limiter, 402 | data chan<- interface{}, options ...gitlab.RequestOptionFunc) error { 403 | 404 | defer wg.Done() 405 | 406 | wg.Lock() 407 | list, resp, err := h.Client.Projects.GetProjectLanguages(project.ID, options...) 408 | wg.Unlock() 409 | if err != nil { 410 | wg.Error(h, err) 411 | return err 412 | } 413 | 414 | data <- sort.Element{ 415 | Host: h, 416 | Struct: &ProjectWithLanguages{ 417 | Project: project, 418 | Languages: list}, 419 | Cached: resp.Header.Get("X-From-Cache") == "1"} 420 | 421 | return nil 422 | } 423 | 424 | type ProjectWithLanguages struct { 425 | Project *gitlab.Project `json:"project,omitempty"` 426 | Languages *gitlab.ProjectLanguages `json:"languages,omitempty"` 427 | } 428 | 429 | type ProjectLanguage struct { 430 | Name string 431 | Percent float32 432 | } 433 | 434 | func (pl ProjectLanguage) String() string { 435 | return fmt.Sprintf("%s: %.2f", pl.Name, pl.Percent) 436 | } 437 | 438 | func (p ProjectWithLanguages) LanguagesToString() string { 439 | if p.Languages == nil || len(*p.Languages) == 0 { 440 | return "-" 441 | } 442 | 443 | languages := make([]*ProjectLanguage, 0, len(*p.Languages)) 444 | for k, v := range *p.Languages { 445 | // insertion sort by percent 446 | idx := go_sort.Search(len(languages), func(i int) bool { return languages[i].Percent <= v }) 447 | if idx == len(languages) { 448 | languages = append(languages, &ProjectLanguage{k, v}) 449 | } else { 450 | languages = append(languages[:idx+1], languages[idx:]...) 451 | languages[idx] = &ProjectLanguage{k, v} 452 | } 453 | } 454 | 455 | names := make([]string, len(languages)) 456 | for i, v := range languages { 457 | names[i] = v.String() 458 | } 459 | 460 | return strings.Join(names, ", ") 461 | } 462 | -------------------------------------------------------------------------------- /cmd/projects/projects.go: -------------------------------------------------------------------------------- 1 | package projects 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | const ( 8 | projectDefaultField = "web_url" 9 | projectWithLanguagesDefaultField = "project.web_url" 10 | ) 11 | 12 | var ( 13 | outputFormat []string 14 | ) 15 | 16 | func NewCmd() *cobra.Command { 17 | cmd := &cobra.Command{ 18 | Use: "projects", 19 | Short: "Projects API", 20 | } 21 | 22 | cmd.PersistentFlags().StringSliceVar(&outputFormat, "output", []string{"table"}, 23 | "Output format: [table csv]. Default: table.") 24 | 25 | cmd.AddCommand( 26 | NewEditCmd(), 27 | NewFilesCmd(), 28 | NewListCmd(), 29 | NewPipelinesCmd(), 30 | NewMergeRequestsCmd(), 31 | NewBranchesCmd(), 32 | NewRegistryCmd(), 33 | NewLanguagesCmd(), 34 | ) 35 | 36 | return cmd 37 | } 38 | -------------------------------------------------------------------------------- /cmd/projects/protected_branches.go: -------------------------------------------------------------------------------- 1 | package projects 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | "text/tabwriter" 8 | 9 | "dario.cat/mergo" 10 | "github.com/flant/glaball/cmd/common" 11 | "github.com/flant/glaball/pkg/client" 12 | "github.com/flant/glaball/pkg/limiter" 13 | "github.com/flant/glaball/pkg/sort/v2" 14 | "github.com/flant/glaball/pkg/util" 15 | "github.com/hashicorp/go-hclog" 16 | "github.com/spf13/cobra" 17 | "github.com/xanzy/go-gitlab" 18 | ) 19 | 20 | const ( 21 | protectedBranchDefaultField = "project.web_url" 22 | ) 23 | 24 | var ( 25 | listProtectedBranchesOptions = gitlab.ListProtectedBranchesOptions{ListOptions: gitlab.ListOptions{PerPage: 100}} 26 | protectRepositoryBranchesOptions = gitlab.ProtectRepositoryBranchesOptions{} 27 | protectedBranchOrderBy []string 28 | forceProtect bool 29 | protectedBranchFormat = util.Dict{ 30 | { 31 | Key: "COUNT", 32 | Value: "[%d]", 33 | }, 34 | { 35 | Key: "REPOSITORY", 36 | Value: "%s", 37 | }, 38 | { 39 | Key: "BRANCHES", 40 | Value: "%s", 41 | }, 42 | { 43 | Key: "HOST", 44 | Value: "[%s]", 45 | }, 46 | { 47 | Key: "CACHED", 48 | Value: "[%s]", 49 | }, 50 | } 51 | ) 52 | 53 | func NewProtectedBranchesCmd() *cobra.Command { 54 | cmd := &cobra.Command{ 55 | Use: "protected", 56 | Short: "Protected branches API", 57 | } 58 | 59 | cmd.AddCommand( 60 | NewProtectedBranchesListCmd(), 61 | NewProtectRepositoryBranchesCmd(), 62 | ) 63 | 64 | return cmd 65 | } 66 | 67 | func NewProtectedBranchesListCmd() *cobra.Command { 68 | cmd := &cobra.Command{ 69 | Use: "list", 70 | Short: "List protected branches", 71 | Long: "Gets a list of protected branches from a project as they are defined in the UI. If a wildcard is set, it is returned instead of the exact name of the branches that match that wildcard.", 72 | RunE: func(cmd *cobra.Command, args []string) error { 73 | return ProtectedBranchesListCmd() 74 | }, 75 | } 76 | 77 | cmd.Flags().Var(util.NewEnumValue(&sortBy, "asc", "desc"), "sort", 78 | "Return protected branches sorted in asc or desc order. Default is desc") 79 | 80 | cmd.Flags().StringSliceVar(&protectedBranchOrderBy, "order_by", []string{"count", protectedBranchDefaultField}, 81 | `Return protected branches ordered by web_url, created_at, title, updated_at or any nested field. Default is web_url.`) 82 | 83 | listProjectsOptionsFlags(cmd, &listProjectsOptions) 84 | 85 | return cmd 86 | } 87 | 88 | func NewProtectRepositoryBranchesCmd() *cobra.Command { 89 | cmd := &cobra.Command{ 90 | Use: "protect", 91 | Short: "Protect repository branches", 92 | Long: "Protects a single repository branch or several project repository branches using a wildcard protected branch.", 93 | RunE: func(cmd *cobra.Command, args []string) error { 94 | return ProtectRepositoryBranchesCmd() 95 | }, 96 | } 97 | 98 | cmd.Flags().BoolVar(&forceProtect, "force", false, 99 | "Force update already protected branches.") 100 | 101 | cmd.Flags().Var(util.NewStringPtrValue(&protectRepositoryBranchesOptions.Name), "name", 102 | "The name of the branch or wildcard") 103 | 104 | cmd.Flags().Var(util.NewAccessLevelValue(&protectRepositoryBranchesOptions.PushAccessLevel), "push_access_level", 105 | "Access levels allowed to push (defaults: 40, Maintainer role)") 106 | 107 | cmd.Flags().Var(util.NewAccessLevelValue(&protectRepositoryBranchesOptions.MergeAccessLevel), "merge_access_level", 108 | "Access levels allowed to merge (defaults: 40, Maintainer role)") 109 | 110 | cmd.Flags().Var(util.NewAccessLevelValue(&protectRepositoryBranchesOptions.UnprotectAccessLevel), "unprotect_access_level", 111 | "Access levels allowed to unprotect (defaults: 40, Maintainer role)") 112 | 113 | cmd.Flags().Var(util.NewBoolPtrValue(&protectRepositoryBranchesOptions.AllowForcePush), "allow_force_push", 114 | "Allow all users with push access to force push. (default: false)") 115 | 116 | // cmd.Flags().Var(util.NewBoolPtrValue(&protectRepositoryBranchesOptions.AllowedToPush), "allowed_to_push", 117 | // "Array of access levels allowed to push, with each described by a hash of the form {user_id: integer}, {group_id: integer}, or {access_level: integer}") 118 | 119 | // cmd.Flags().Var(util.NewBoolPtrValue(&protectRepositoryBranchesOptions.AllowedToMerge), "allowed_to_merge", 120 | // "Array of access levels allowed to merge, with each described by a hash of the form {user_id: integer}, {group_id: integer}, or {access_level: integer}") 121 | 122 | // cmd.Flags().Var(util.NewBoolPtrValue(&protectRepositoryBranchesOptions.AllowedToUnprotect), "allowed_to_unprotect", 123 | // "Array of access levels allowed to unprotect, with each described by a hash of the form {user_id: integer}, {group_id: integer}, or {access_level: integer}") 124 | 125 | cmd.Flags().Var(util.NewBoolPtrValue(&protectRepositoryBranchesOptions.CodeOwnerApprovalRequired), "code_owner_approval_required", 126 | "Prevent pushes to this branch if it matches an item in the CODEOWNERS file. (defaults: false)") 127 | 128 | cmd.MarkFlagRequired("name") 129 | 130 | listProjectsOptionsFlags(cmd, &listProjectsOptions) 131 | 132 | return cmd 133 | } 134 | 135 | type ProjectProtectedBranch struct { 136 | Project *gitlab.Project `json:"project,omitempty"` 137 | ProtectedBranches []*gitlab.ProtectedBranch `json:"protected_branches,omitempty"` 138 | } 139 | 140 | func (pb *ProjectProtectedBranch) BranchesNames() []string { 141 | switch len(pb.ProtectedBranches) { 142 | case 0: 143 | return []string{} 144 | case 1: 145 | return []string{pb.ProtectedBranches[0].Name} 146 | } 147 | 148 | branches := make([]string, 0, len(pb.ProtectedBranches)) 149 | for _, b := range pb.ProtectedBranches { 150 | branches = util.InsertString(branches, b.Name) 151 | } 152 | return branches 153 | } 154 | 155 | func (pb *ProjectProtectedBranch) Search(name string) (*gitlab.ProtectedBranch, bool) { 156 | switch len(pb.ProtectedBranches) { 157 | case 0: 158 | return nil, false 159 | case 1: 160 | return pb.ProtectedBranches[0], pb.ProtectedBranches[0].Name == name 161 | } 162 | // linear search 163 | for _, b := range pb.ProtectedBranches { 164 | if b.Name == name { 165 | return b, true 166 | } 167 | } 168 | return nil, false 169 | } 170 | 171 | func ProtectedBranchesListCmd() error { 172 | if !sort.ValidOrderBy(protectedBranchOrderBy, ProjectProtectedBranch{}) { 173 | protectedBranchOrderBy = append(protectedBranchOrderBy, protectedBranchDefaultField) 174 | } 175 | 176 | wg := common.Limiter 177 | data := make(chan interface{}) 178 | defer func() { 179 | for _, err := range wg.Errors() { 180 | hclog.L().Error(err.Err.Error()) 181 | } 182 | }() 183 | 184 | for _, h := range common.Client.Hosts { 185 | fmt.Printf("Getting protected branches from %s ...\n", h.URL) 186 | wg.Add(1) 187 | go listProjects(h, listProjectsOptions, wg, data, common.Client.WithCache()) 188 | } 189 | 190 | go func() { 191 | wg.Wait() 192 | close(data) 193 | }() 194 | 195 | toList := make(sort.Elements, 0) 196 | for e := range data { 197 | toList = append(toList, e) 198 | } 199 | 200 | if len(toList) == 0 { 201 | return fmt.Errorf("no projects found") 202 | } 203 | 204 | protectedBranches := make(chan interface{}) 205 | for _, v := range toList.Typed() { 206 | wg.Add(1) 207 | go listProtectedBranches(v.Host, v.Struct.(*gitlab.Project), listProtectedBranchesOptions, wg, protectedBranches, common.Client.WithCache()) 208 | } 209 | 210 | go func() { 211 | wg.Wait() 212 | close(protectedBranches) 213 | }() 214 | 215 | results, err := sort.FromChannel(protectedBranches, &sort.Options{ 216 | OrderBy: protectedBranchOrderBy, 217 | SortBy: sortBy, 218 | GroupBy: protectedBranchDefaultField, 219 | StructType: ProjectProtectedBranch{}, 220 | }) 221 | if err != nil { 222 | return err 223 | } 224 | 225 | if len(results) == 0 { 226 | return fmt.Errorf("no protected branches found") 227 | } 228 | 229 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', tabwriter.TabIndent) 230 | if _, err := fmt.Fprintln(w, strings.Join(protectedBranchFormat.Keys(), "\t")); err != nil { 231 | return err 232 | } 233 | 234 | unique := 0 235 | total := 0 236 | 237 | for _, r := range results { 238 | unique++ 239 | total += r.Count 240 | for _, v := range r.Elements.Typed() { 241 | if err := protectedBranchFormat.Print(w, "\t", 242 | r.Count, 243 | r.Key, 244 | v.Struct.(*ProjectProtectedBranch).BranchesNames(), 245 | v.Host.ProjectName(), 246 | r.Cached, 247 | ); err != nil { 248 | return err 249 | } 250 | } 251 | 252 | } 253 | 254 | if err := totalFormat.Print(w, "\n", unique, total, len(wg.Errors())); err != nil { 255 | return err 256 | } 257 | 258 | if err := w.Flush(); err != nil { 259 | return err 260 | } 261 | 262 | return nil 263 | } 264 | 265 | func ProtectRepositoryBranchesCmd() error { 266 | if !sort.ValidOrderBy(protectedBranchOrderBy, ProjectProtectedBranch{}) { 267 | protectedBranchOrderBy = append(protectedBranchOrderBy, protectedBranchDefaultField) 268 | } 269 | 270 | wg := common.Limiter 271 | data := make(chan interface{}) 272 | defer func() { 273 | for _, err := range wg.Errors() { 274 | hclog.L().Error(err.Err.Error()) 275 | } 276 | }() 277 | 278 | for _, h := range common.Client.Hosts { 279 | fmt.Printf("Getting protected branches from %s ...\n", h.URL) 280 | wg.Add(1) 281 | go listProjects(h, listProjectsOptions, wg, data, common.Client.WithCache()) 282 | } 283 | 284 | go func() { 285 | wg.Wait() 286 | close(data) 287 | }() 288 | 289 | toList := make(sort.Elements, 0) 290 | for e := range data { 291 | toList = append(toList, e) 292 | } 293 | 294 | if len(toList) == 0 { 295 | return fmt.Errorf("no projects found") 296 | } 297 | 298 | protectedBranches := make(chan interface{}) 299 | for _, v := range toList.Typed() { 300 | wg.Add(1) 301 | go listProtectedBranches(v.Host, v.Struct.(*gitlab.Project), listProtectedBranchesOptions, wg, protectedBranches, common.Client.WithCache()) 302 | } 303 | 304 | go func() { 305 | wg.Wait() 306 | close(protectedBranches) 307 | }() 308 | 309 | toProtect := make(sort.Elements, 0) 310 | for e := range protectedBranches { 311 | if v := e.(sort.Element).Struct.(*ProjectProtectedBranch); forceProtect || len(v.ProtectedBranches) == 0 { 312 | toProtect = append(toProtect, e) 313 | } 314 | } 315 | 316 | if len(toProtect) == 0 { 317 | return fmt.Errorf("branch %q is already protected in %d repositories in %v", 318 | *protectRepositoryBranchesOptions.Name, len(toList), common.Client.Hosts.Projects(common.Config.ShowAll)) 319 | } 320 | 321 | util.AskUser(fmt.Sprintf("Do you really want to protect branch %q in %d repositories in %v ?", 322 | *protectRepositoryBranchesOptions.Name, len(toProtect), common.Client.Hosts.Projects(common.Config.ShowAll))) 323 | 324 | protectedCh := make(chan interface{}) 325 | for _, v := range toProtect.Typed() { 326 | wg.Add(1) 327 | go protectRepositoryBranches(v.Host, v.Struct.(*ProjectProtectedBranch), forceProtect, protectRepositoryBranchesOptions, wg, protectedCh, common.Client.WithNoCache()) 328 | } 329 | 330 | go func() { 331 | wg.Wait() 332 | close(protectedCh) 333 | }() 334 | 335 | results, err := sort.FromChannel(protectedCh, &sort.Options{ 336 | OrderBy: protectedBranchOrderBy, 337 | SortBy: sortBy, 338 | GroupBy: protectedBranchDefaultField, 339 | StructType: ProjectProtectedBranch{}, 340 | }) 341 | if err != nil { 342 | return err 343 | } 344 | 345 | if len(results) == 0 { 346 | return fmt.Errorf("no protected branches found") 347 | } 348 | 349 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', tabwriter.TabIndent) 350 | if _, err := fmt.Fprintln(w, strings.Join(protectedBranchFormat.Keys(), "\t")); err != nil { 351 | return err 352 | } 353 | 354 | unique := 0 355 | total := 0 356 | 357 | for _, r := range results { 358 | unique++ 359 | total += r.Count 360 | for _, v := range r.Elements.Typed() { 361 | if err := protectedBranchFormat.Print(w, "\t", 362 | r.Count, 363 | r.Key, 364 | v.Struct.(*ProjectProtectedBranch).BranchesNames(), 365 | v.Host.ProjectName(), 366 | r.Cached, 367 | ); err != nil { 368 | return err 369 | } 370 | } 371 | 372 | } 373 | 374 | if err := totalFormat.Print(w, "\n", unique, total, len(wg.Errors())); err != nil { 375 | return err 376 | } 377 | 378 | if err := w.Flush(); err != nil { 379 | return err 380 | } 381 | 382 | return nil 383 | } 384 | 385 | func listProtectedBranches(h *client.Host, project *gitlab.Project, opt gitlab.ListProtectedBranchesOptions, 386 | wg *limiter.Limiter, data chan<- interface{}, options ...gitlab.RequestOptionFunc) error { 387 | 388 | defer wg.Done() 389 | 390 | wg.Lock() 391 | list, resp, err := h.Client.ProtectedBranches.ListProtectedBranches(project.ID, &opt, options...) 392 | wg.Unlock() 393 | if err != nil { 394 | wg.Error(h, err) 395 | return err 396 | } 397 | 398 | data <- sort.Element{ 399 | Host: h, 400 | Struct: &ProjectProtectedBranch{ 401 | Project: project, 402 | ProtectedBranches: list}, 403 | Cached: resp.Header.Get("X-From-Cache") == "1"} 404 | 405 | if resp.NextPage > 0 { 406 | wg.Add(1) 407 | opt.Page = resp.NextPage 408 | go listProtectedBranches(h, project, opt, wg, data, options...) 409 | } 410 | 411 | return nil 412 | } 413 | 414 | func protectRepositoryBranches(h *client.Host, pb *ProjectProtectedBranch, forceProtect bool, opt gitlab.ProtectRepositoryBranchesOptions, 415 | wg *limiter.Limiter, data chan<- interface{}, options ...gitlab.RequestOptionFunc) error { 416 | 417 | defer wg.Done() 418 | 419 | if forceProtect { 420 | if old, ok := pb.Search(*opt.Name); ok { 421 | new := opt 422 | 423 | new.AllowForcePush = &old.AllowForcePush 424 | new.CodeOwnerApprovalRequired = &old.CodeOwnerApprovalRequired 425 | 426 | switch n := len(old.MergeAccessLevels); n { 427 | case 0: 428 | case 1: 429 | new.MergeAccessLevel = &old.MergeAccessLevels[0].AccessLevel 430 | default: 431 | allowedToMerge := make([]*gitlab.BranchPermissionOptions, 0, n) 432 | for _, l := range old.MergeAccessLevels { 433 | allowedToMerge = append(allowedToMerge, &gitlab.BranchPermissionOptions{ 434 | UserID: &l.UserID, 435 | GroupID: &l.GroupID, 436 | AccessLevel: &l.AccessLevel, 437 | }) 438 | } 439 | new.AllowedToMerge = &allowedToMerge 440 | } 441 | 442 | switch n := len(old.PushAccessLevels); n { 443 | case 0: 444 | case 1: 445 | new.PushAccessLevel = &old.PushAccessLevels[0].AccessLevel 446 | default: 447 | allowedToPush := make([]*gitlab.BranchPermissionOptions, 0, n) 448 | for _, l := range old.PushAccessLevels { 449 | allowedToPush = append(allowedToPush, &gitlab.BranchPermissionOptions{ 450 | UserID: &l.UserID, 451 | GroupID: &l.GroupID, 452 | AccessLevel: &l.AccessLevel, 453 | }) 454 | } 455 | new.AllowedToPush = &allowedToPush 456 | } 457 | 458 | switch n := len(old.UnprotectAccessLevels); n { 459 | case 0: 460 | case 1: 461 | new.UnprotectAccessLevel = &old.UnprotectAccessLevels[0].AccessLevel 462 | default: 463 | allowedToUnprotect := make([]*gitlab.BranchPermissionOptions, 0, n) 464 | for _, l := range old.UnprotectAccessLevels { 465 | allowedToUnprotect = append(allowedToUnprotect, &gitlab.BranchPermissionOptions{ 466 | UserID: &l.UserID, 467 | GroupID: &l.GroupID, 468 | AccessLevel: &l.AccessLevel, 469 | }) 470 | } 471 | new.AllowedToUnprotect = &allowedToUnprotect 472 | } 473 | 474 | if err := mergo.Merge(&new, opt, mergo.WithOverwriteWithEmptyValue); err != nil { 475 | wg.Error(h, err) 476 | return err 477 | } 478 | 479 | wg.Lock() 480 | _, err := h.Client.ProtectedBranches.UnprotectRepositoryBranches(pb.Project.ID, *new.Name, options...) 481 | wg.Unlock() 482 | if err != nil { 483 | wg.Error(h, err) 484 | return err 485 | } 486 | 487 | opt = new 488 | } 489 | } 490 | 491 | wg.Lock() 492 | v, resp, err := h.Client.ProtectedBranches.ProtectRepositoryBranches(pb.Project.ID, &opt, options...) 493 | wg.Unlock() 494 | if err != nil { 495 | wg.Error(h, err) 496 | return err 497 | } 498 | 499 | data <- sort.Element{ 500 | Host: h, 501 | Struct: &ProjectProtectedBranch{ 502 | Project: pb.Project, 503 | ProtectedBranches: []*gitlab.ProtectedBranch{v}}, 504 | Cached: resp.Header.Get("X-From-Cache") == "1"} 505 | 506 | return nil 507 | } 508 | -------------------------------------------------------------------------------- /cmd/projects/registry.go: -------------------------------------------------------------------------------- 1 | package projects 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | "text/tabwriter" 8 | 9 | "github.com/alecthomas/units" 10 | "github.com/flant/glaball/cmd/common" 11 | "github.com/flant/glaball/pkg/client" 12 | "github.com/flant/glaball/pkg/limiter" 13 | "github.com/flant/glaball/pkg/sort/v2" 14 | "github.com/flant/glaball/pkg/util" 15 | "github.com/hashicorp/go-hclog" 16 | "github.com/spf13/cobra" 17 | "github.com/xanzy/go-gitlab" 18 | ) 19 | 20 | const ( 21 | registryRepositoryDefaultField = "project.web_url" 22 | ) 23 | 24 | var ( 25 | listRegistryRepositoriesOptions = gitlab.ListRegistryRepositoriesOptions{ 26 | ListOptions: gitlab.ListOptions{PerPage: 100}, 27 | TagsCount: gitlab.Bool(true), 28 | } 29 | registryRepositoryTotalSize bool 30 | registryRepositoryhOrderBy []string 31 | registryRepositoriesFormat = util.Dict{ 32 | { 33 | Key: "COUNT", 34 | Value: "[%d]", 35 | }, 36 | { 37 | Key: "REPOSITORY", 38 | Value: "%s", 39 | }, 40 | { 41 | Key: "TAGS COUNT", 42 | Value: "[%d]", 43 | }, 44 | { 45 | Key: "TOTAL SIZE", 46 | Value: "%s", 47 | }, 48 | { 49 | Key: "HOST", 50 | Value: "[%s]", 51 | }, 52 | { 53 | Key: "CACHED", 54 | Value: "[%s]", 55 | }, 56 | } 57 | ) 58 | 59 | func NewRegistryCmd() *cobra.Command { 60 | cmd := &cobra.Command{ 61 | Use: "registry", 62 | Short: "Container Registry API", 63 | } 64 | 65 | cmd.AddCommand( 66 | NewRegistryListCmd(), 67 | ) 68 | 69 | return cmd 70 | } 71 | 72 | func NewRegistryListCmd() *cobra.Command { 73 | cmd := &cobra.Command{ 74 | Use: "list", 75 | Short: "List registry repositories", 76 | Long: "Get a list of registry repositories in a project.", 77 | RunE: func(cmd *cobra.Command, args []string) error { 78 | return RegistryListCmd() 79 | }, 80 | } 81 | 82 | cmd.Flags().Var(util.NewEnumValue(&sortBy, "asc", "desc"), "sort", 83 | "Return protected branches sorted in asc or desc order. Default is desc") 84 | 85 | cmd.Flags().StringSliceVar(®istryRepositoryhOrderBy, "order_by", []string{"count", registryRepositoryDefaultField}, 86 | `Return protected branches ordered by web_url, created_at, title, updated_at or any nested field. Default is web_url.`) 87 | 88 | cmd.Flags().BoolVar(®istryRepositoryTotalSize, "size", false, 89 | `If the parameter is included as true, the response includes "size". This is the deduplicated size of all images within the repository.`) 90 | 91 | listProjectsOptionsFlags(cmd, &listProjectsOptions) 92 | 93 | return cmd 94 | } 95 | 96 | func RegistryListCmd() error { 97 | if !sort.ValidOrderBy(registryRepositoryhOrderBy, ProjectRegistryRepository{}) { 98 | registryRepositoryhOrderBy = append(registryRepositoryhOrderBy, registryRepositoryDefaultField) 99 | } 100 | 101 | if registryRepositoryTotalSize { 102 | listRegistryRepositoriesOptions.Tags = ®istryRepositoryTotalSize 103 | } 104 | 105 | wg := common.Limiter 106 | data := make(chan interface{}) 107 | defer func() { 108 | for _, err := range wg.Errors() { 109 | hclog.L().Error(err.Err.Error()) 110 | } 111 | }() 112 | 113 | for _, h := range common.Client.Hosts { 114 | fmt.Printf("Getting registry repositories from %s ...\n", h.URL) 115 | wg.Add(1) 116 | go listProjects(h, listProjectsOptions, wg, data, common.Client.WithCache()) 117 | } 118 | 119 | go func() { 120 | wg.Wait() 121 | close(data) 122 | }() 123 | 124 | toList := make(sort.Elements, 0) 125 | for e := range data { 126 | toList = append(toList, e) 127 | } 128 | 129 | if len(toList) == 0 { 130 | return fmt.Errorf("no projects found") 131 | } 132 | 133 | registryRepositories := make(chan interface{}) 134 | for _, v := range toList.Typed() { 135 | wg.Add(1) 136 | go listRegistryRepositories(v.Host, v.Struct.(*gitlab.Project), listRegistryRepositoriesOptions, wg, registryRepositories, common.Client.WithCache()) 137 | } 138 | 139 | go func() { 140 | wg.Wait() 141 | close(registryRepositories) 142 | }() 143 | 144 | if registryRepositoryTotalSize { 145 | registryRepositoriesList := make(sort.Elements, 0) 146 | for v := range registryRepositories { 147 | e := v.(sort.Element) 148 | p := e.Struct.(*ProjectRegistryRepository) 149 | for _, r := range p.RegistryRepositories { 150 | for _, tag := range r.Tags { 151 | wg.Add(1) 152 | go getRegistryRepositoryTagDetail(e.Host, p.Project, r, tag, wg, common.Client.WithCache()) 153 | } 154 | } 155 | registryRepositoriesList = append(registryRepositoriesList, v) 156 | } 157 | wg.Wait() 158 | registryRepositories = make(chan interface{}) 159 | go func() { 160 | for _, v := range registryRepositoriesList { 161 | registryRepositories <- v 162 | } 163 | close(registryRepositories) 164 | }() 165 | } 166 | 167 | results, err := sort.FromChannel(registryRepositories, &sort.Options{ 168 | OrderBy: registryRepositoryhOrderBy, 169 | SortBy: sortBy, 170 | GroupBy: registryRepositoryDefaultField, 171 | StructType: ProjectRegistryRepository{}, 172 | }) 173 | if err != nil { 174 | return err 175 | } 176 | 177 | if len(results) == 0 { 178 | return fmt.Errorf("no registry repositories found") 179 | } 180 | 181 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', tabwriter.TabIndent) 182 | if _, err := fmt.Fprintln(w, strings.Join(registryRepositoriesFormat.Keys(), "\t")); err != nil { 183 | return err 184 | } 185 | 186 | unique := 0 187 | total := 0 188 | 189 | for _, r := range results { 190 | unique++ 191 | total += r.Count 192 | for _, v := range r.Elements.Typed() { 193 | pr := v.Struct.(*ProjectRegistryRepository) 194 | if err := registryRepositoriesFormat.Print(w, "\t", 195 | len(pr.RegistryRepositories), 196 | r.Key, 197 | pr.TagsCount(), 198 | units.Base2Bytes(pr.TotalSize()).Floor(), 199 | v.Host.ProjectName(), 200 | r.Cached, 201 | ); err != nil { 202 | return err 203 | } 204 | } 205 | 206 | } 207 | 208 | if err := totalFormat.Print(w, "\n", unique, total, len(wg.Errors())); err != nil { 209 | return err 210 | } 211 | 212 | if err := w.Flush(); err != nil { 213 | return err 214 | } 215 | 216 | return nil 217 | } 218 | 219 | type ProjectRegistryRepository struct { 220 | Project *gitlab.Project `json:"project,omitempty"` 221 | RegistryRepositories []*gitlab.RegistryRepository `json:"registry_repositories,omitempty"` 222 | } 223 | 224 | func (pr *ProjectRegistryRepository) TagsCount() (i int) { 225 | for _, v := range pr.RegistryRepositories { 226 | i += v.TagsCount 227 | } 228 | return i 229 | } 230 | 231 | func (pr *ProjectRegistryRepository) TotalSize() (i int) { 232 | digests := make(map[string]struct{}) 233 | if registryRepositoryTotalSize { 234 | for _, v := range pr.RegistryRepositories { 235 | for _, t := range v.Tags { 236 | // deduplicate size 237 | if _, ok := digests[t.Digest]; !ok { 238 | i += t.TotalSize 239 | digests[t.Digest] = struct{}{} 240 | } 241 | } 242 | } 243 | } 244 | return i 245 | } 246 | 247 | func listRegistryRepositories(h *client.Host, project *gitlab.Project, opt gitlab.ListRegistryRepositoriesOptions, 248 | wg *limiter.Limiter, data chan<- interface{}, options ...gitlab.RequestOptionFunc) { 249 | 250 | defer wg.Done() 251 | 252 | wg.Lock() 253 | list, resp, err := h.Client.ContainerRegistry.ListProjectRegistryRepositories(project.ID, &opt, options...) 254 | wg.Unlock() 255 | if err != nil { 256 | wg.Error(h, err) 257 | return 258 | } 259 | 260 | data <- sort.Element{ 261 | Host: h, 262 | Struct: &ProjectRegistryRepository{ 263 | Project: project, 264 | RegistryRepositories: list}, 265 | Cached: resp.Header.Get("X-From-Cache") == "1"} 266 | 267 | if resp.NextPage > 0 { 268 | wg.Add(1) 269 | opt.Page = resp.NextPage 270 | go listRegistryRepositories(h, project, opt, wg, data, options...) 271 | } 272 | } 273 | 274 | func getRegistryRepositoryTagDetail(h *client.Host, project *gitlab.Project, repository *gitlab.RegistryRepository, 275 | tag *gitlab.RegistryRepositoryTag, wg *limiter.Limiter, options ...gitlab.RequestOptionFunc) { 276 | 277 | defer wg.Done() 278 | 279 | wg.Lock() 280 | v, _, err := h.Client.ContainerRegistry.GetRegistryRepositoryTagDetail(project.ID, repository.ID, tag.Name, options...) 281 | wg.Unlock() 282 | if err != nil { 283 | wg.Error(h, err) 284 | return 285 | } 286 | 287 | *tag = *v 288 | } 289 | -------------------------------------------------------------------------------- /cmd/users/block.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "regexp" 7 | "text/tabwriter" 8 | 9 | "github.com/flant/glaball/pkg/client" 10 | "github.com/flant/glaball/pkg/limiter" 11 | "github.com/flant/glaball/pkg/sort/v2" 12 | "github.com/flant/glaball/pkg/util" 13 | 14 | "github.com/flant/glaball/cmd/common" 15 | 16 | "github.com/hashicorp/go-hclog" 17 | "github.com/spf13/cobra" 18 | "github.com/xanzy/go-gitlab" 19 | ) 20 | 21 | var ( 22 | blockBy string 23 | blockFieldRegexp *regexp.Regexp 24 | blockHosts bool 25 | ) 26 | 27 | func NewBlockCmd() *cobra.Command { 28 | cmd := &cobra.Command{ 29 | Use: "block --by=[email|username|name] [regexp]", 30 | Short: "Blocks an existing user", 31 | Long: "Blocks an existing user. Only administrators can change attributes of a user.", 32 | Args: cobra.ExactArgs(1), 33 | RunE: func(cmd *cobra.Command, args []string) error { 34 | re, err := regexp.Compile(args[0]) 35 | if err != nil { 36 | return err 37 | } 38 | blockFieldRegexp = re 39 | return Block() 40 | }, 41 | } 42 | 43 | cmd.Flags().Var(util.NewEnumValue(&blockBy, "email", "username", "name"), "by", "Search user you want to block") 44 | cmd.MarkFlagRequired("by") 45 | 46 | cmd.Flags().BoolVar(&blockHosts, "hosts", false, "List hosts where user exists") 47 | 48 | return cmd 49 | } 50 | 51 | func Block() error { 52 | wg := common.Limiter 53 | data := make(chan interface{}) 54 | 55 | fmt.Printf("Searching for user %q...\n", blockFieldRegexp) 56 | for _, h := range common.Client.Hosts { 57 | wg.Add(1) 58 | go listUsersSearch(h, blockBy, blockFieldRegexp, gitlab.ListUsersOptions{ 59 | ListOptions: gitlab.ListOptions{ 60 | PerPage: 100, 61 | }, 62 | }, wg, data, common.Client.WithNoCache()) 63 | } 64 | 65 | go func() { 66 | wg.Wait() 67 | close(data) 68 | }() 69 | 70 | toBlock := make(sort.Elements, 0) 71 | for e := range data { 72 | toBlock = append(toBlock, e) 73 | } 74 | 75 | if len(toBlock) == 0 { 76 | return fmt.Errorf("user not found: %s", blockFieldRegexp) 77 | } 78 | 79 | if blockHosts { 80 | for _, h := range toBlock.Hosts() { 81 | fmt.Println(h.Project) 82 | } 83 | return nil 84 | } 85 | 86 | util.AskUser(fmt.Sprintf("Do you really want to block %d user(s) %q in %d gitlab(s) %v ?", 87 | len(toBlock), blockFieldRegexp, len(toBlock.Hosts()), toBlock.Hosts().Projects(common.Config.ShowAll))) 88 | 89 | blocked := make(chan interface{}) 90 | for _, v := range toBlock.Typed() { 91 | wg.Add(1) 92 | go blockUser(v.Host, v.Struct.(*gitlab.User), wg, blocked) 93 | } 94 | 95 | go func() { 96 | wg.Wait() 97 | close(blocked) 98 | }() 99 | 100 | results, err := sort.FromChannel(blocked, &sort.Options{ 101 | OrderBy: []string{blockBy}, 102 | StructType: gitlab.User{}, 103 | }) 104 | if err != nil { 105 | return err 106 | } 107 | 108 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', tabwriter.TabIndent) 109 | fmt.Fprintf(w, "COUNT\tUSER\tHOSTS\tCACHED\n") 110 | 111 | total := 0 112 | for _, v := range results { 113 | fmt.Fprintf(w, "[%d]\t%s\t%s\t[%s]\n", v.Count, v.Key, v.Elements.Hosts().Projects(common.Config.ShowAll), v.Cached) 114 | total++ 115 | } 116 | 117 | fmt.Fprintf(w, "Blocked: %d\nErrors: %d\n", total, len(wg.Errors())) 118 | 119 | w.Flush() 120 | 121 | for _, err := range wg.Errors() { 122 | hclog.L().Error(err.Err.Error()) 123 | } 124 | 125 | return nil 126 | 127 | } 128 | 129 | func blockUser(h *client.Host, user *gitlab.User, wg *limiter.Limiter, data chan<- interface{}, 130 | options ...gitlab.RequestOptionFunc) { 131 | 132 | defer wg.Done() 133 | 134 | wg.Lock() 135 | err := h.Client.Users.BlockUser(user.ID, options...) 136 | if err != nil { 137 | wg.Error(h, err) 138 | wg.Unlock() 139 | return 140 | } 141 | wg.Unlock() 142 | 143 | data <- sort.Element{Host: h, Struct: user, Cached: false} 144 | } 145 | -------------------------------------------------------------------------------- /cmd/users/create.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "text/tabwriter" 7 | 8 | "github.com/flant/glaball/pkg/client" 9 | "github.com/flant/glaball/pkg/limiter" 10 | "github.com/flant/glaball/pkg/sort/v2" 11 | "github.com/flant/glaball/pkg/util" 12 | 13 | "github.com/flant/glaball/cmd/common" 14 | 15 | "github.com/hashicorp/go-hclog" 16 | "github.com/spf13/cobra" 17 | "github.com/xanzy/go-gitlab" 18 | ) 19 | 20 | var ( 21 | createOpt = gitlab.CreateUserOptions{} 22 | ) 23 | 24 | func NewCreateCmd() *cobra.Command { 25 | cmd := &cobra.Command{ 26 | Use: "create", 27 | Short: "Create user", 28 | Long: `Creates a new user. Note only administrators can create new users. 29 | Either --password, --reset_password, or --force_random_password must be specified. 30 | If --reset_password and --force_random_password are both false, then --password is required. 31 | 32 | --force_random_password and --reset_password take priority over --password. 33 | In addition, --reset_password and --force_random_password can be used together.`, 34 | RunE: func(cmd *cobra.Command, args []string) error { 35 | return Create() 36 | }, 37 | } 38 | 39 | // CreateUserOptions 40 | cmd.Flags().Var(util.NewStringPtrValue(&createOpt.Email), "email", "Email.") 41 | cmd.Flags().Var(util.NewStringPtrValue(&createOpt.Password), "password", "Password.") 42 | cmd.Flags().Var(util.NewBoolPtrValue(&createOpt.ResetPassword), "reset_password", 43 | "Send user password reset link - true or false (default).") 44 | cmd.Flags().Var(util.NewBoolPtrValue(&createOpt.ForceRandomPassword), "force_random_password", 45 | "Set user password to a random value - true or false (default).") 46 | cmd.Flags().Var(util.NewStringPtrValue(&createOpt.Username), "username", "Username.") 47 | cmd.Flags().Var(util.NewStringPtrValue(&createOpt.Name), "name", "Name.") 48 | cmd.Flags().Var(util.NewStringPtrValue(&createOpt.Skype), "skype", "Skype ID.") 49 | cmd.Flags().Var(util.NewStringPtrValue(&createOpt.Linkedin), "linkedin", "LinkedIn.") 50 | cmd.Flags().Var(util.NewStringPtrValue(&createOpt.Twitter), "twitter", "Twitter account.") 51 | cmd.Flags().Var(util.NewStringPtrValue(&createOpt.WebsiteURL), "website_url", "Website URL.") 52 | cmd.Flags().Var(util.NewStringPtrValue(&createOpt.Organization), "organization", "Organization name.") 53 | cmd.Flags().Var(util.NewIntPtrValue(&createOpt.ProjectsLimit), "projects_limit", 54 | "Number of projects user can create.") 55 | cmd.Flags().Var(util.NewStringPtrValue(&createOpt.ExternUID), "extern_uid", "External UID.") 56 | cmd.Flags().Var(util.NewStringPtrValue(&createOpt.Provider), "provider", "External provider name.") 57 | cmd.Flags().Var(util.NewStringPtrValue(&createOpt.Bio), "bio", "User's biography.") 58 | cmd.Flags().Var(util.NewStringPtrValue(&createOpt.Location), "location", "User's location.") 59 | cmd.Flags().Var(util.NewBoolPtrValue(&createOpt.Admin), "admin", "User is admin - true or false (default).") 60 | cmd.Flags().Var(util.NewBoolPtrValue(&createOpt.CanCreateGroup), "can_create_group", 61 | "User can create groups - true or false.") 62 | cmd.Flags().Var(util.NewBoolPtrValue(&createOpt.SkipConfirmation), "skip_confirmation", 63 | "Skip confirmation - true or false (default).") 64 | cmd.Flags().Var(util.NewBoolPtrValue(&createOpt.External), "external", 65 | "Flags the user as external - true or false. Default is false") 66 | cmd.Flags().Var(util.NewBoolPtrValue(&createOpt.PrivateProfile), "private_profile", 67 | "User’s profile is private - true, false (default), or null (is converted to false).") 68 | cmd.Flags().Var(util.NewStringPtrValue(&createOpt.Note), "note", "Admin notes for this user.") 69 | 70 | cmd.MarkFlagRequired("email") 71 | cmd.MarkFlagRequired("username") 72 | cmd.MarkFlagRequired("name") 73 | 74 | return cmd 75 | } 76 | 77 | func Create() error { 78 | if createOpt.Password == nil && 79 | (createOpt.ResetPassword == nil || !*createOpt.ResetPassword) && 80 | (createOpt.ForceRandomPassword == nil || !*createOpt.ForceRandomPassword) { 81 | return fmt.Errorf("--password, --reset_password, --force_random_password are missing, at least one parameter must be provided") 82 | } 83 | 84 | util.AskUser(fmt.Sprintf("Do you really want to create user %q in %v ?", 85 | *createOpt.Username, common.Client.Hosts.Projects(common.Config.ShowAll))) 86 | 87 | wg := common.Limiter 88 | data := make(chan interface{}) 89 | 90 | for _, h := range common.Client.Hosts { 91 | wg.Add(1) 92 | go createUser(h, createOpt, wg, data) 93 | } 94 | 95 | go func() { 96 | wg.Wait() 97 | close(data) 98 | }() 99 | 100 | results, err := sort.FromChannel(data, &sort.Options{ 101 | OrderBy: []string{"username"}, 102 | StructType: gitlab.User{}, 103 | }) 104 | if err != nil { 105 | return err 106 | } 107 | 108 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', tabwriter.TabIndent) 109 | fmt.Fprintf(w, "COUNT\tUSER\tHOSTS\tCACHED\n") 110 | 111 | total := 0 112 | for _, v := range results { 113 | fmt.Fprintf(w, "[%d]\t%s\t%s\t[%s]\n", v.Count, v.Key, v.Elements.Hosts().Projects(common.Config.ShowAll), v.Cached) 114 | total++ 115 | } 116 | 117 | fmt.Fprintf(w, "Created: %d\nErrors: %d\n", total, len(wg.Errors())) 118 | 119 | w.Flush() 120 | 121 | for _, err := range wg.Errors() { 122 | hclog.L().Error(err.Err.Error()) 123 | } 124 | 125 | return nil 126 | 127 | } 128 | 129 | func createUser(h *client.Host, opt gitlab.CreateUserOptions, wg *limiter.Limiter, data chan<- interface{}, 130 | options ...gitlab.RequestOptionFunc) { 131 | 132 | defer wg.Done() 133 | 134 | wg.Lock() 135 | user, resp, err := h.Client.Users.CreateUser(&opt, options...) 136 | if err != nil { 137 | wg.Error(h, err) 138 | wg.Unlock() 139 | return 140 | } 141 | wg.Unlock() 142 | 143 | data <- sort.Element{Host: h, Struct: user, Cached: resp.Header.Get("X-From-Cache") == "1"} 144 | } 145 | -------------------------------------------------------------------------------- /cmd/users/delete.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "regexp" 7 | "text/tabwriter" 8 | 9 | "github.com/flant/glaball/pkg/client" 10 | "github.com/flant/glaball/pkg/limiter" 11 | "github.com/flant/glaball/pkg/sort/v2" 12 | "github.com/flant/glaball/pkg/util" 13 | 14 | "github.com/flant/glaball/cmd/common" 15 | 16 | "github.com/hashicorp/go-hclog" 17 | "github.com/spf13/cobra" 18 | "github.com/xanzy/go-gitlab" 19 | ) 20 | 21 | var ( 22 | deleteBy string 23 | deleteFieldRegexp *regexp.Regexp 24 | deleteHosts bool 25 | ) 26 | 27 | func NewDeleteCmd() *cobra.Command { 28 | cmd := &cobra.Command{ 29 | Use: "delete --by=[email|username|name] [regexp]", 30 | Short: "Deletes a user", 31 | Long: `Deletes a user. Available only for administrators. 32 | This returns a 204 No Content status code if the operation was successfully, 33 | 404 if the resource was not found or 409 if the user cannot be soft deleted.`, 34 | Args: cobra.ExactArgs(1), 35 | RunE: func(cmd *cobra.Command, args []string) error { 36 | re, err := regexp.Compile(args[0]) 37 | if err != nil { 38 | return err 39 | } 40 | deleteFieldRegexp = re 41 | return Delete() 42 | }, 43 | } 44 | 45 | cmd.Flags().Var(util.NewEnumValue(&deleteBy, "email", "username", "name"), "by", "Search user you want to delete") 46 | cmd.MarkFlagRequired("by") 47 | 48 | cmd.Flags().BoolVar(&deleteHosts, "hosts", false, "List hosts where user exists") 49 | 50 | return cmd 51 | } 52 | 53 | func Delete() error { 54 | wg := common.Limiter 55 | data := make(chan interface{}) 56 | 57 | fmt.Printf("Searching for user %q...\n", deleteFieldRegexp) 58 | for _, h := range common.Client.Hosts { 59 | wg.Add(1) 60 | go listUsersSearch(h, deleteBy, deleteFieldRegexp, gitlab.ListUsersOptions{ 61 | ListOptions: gitlab.ListOptions{ 62 | PerPage: 100, 63 | }, 64 | }, wg, data) 65 | } 66 | 67 | go func() { 68 | wg.Wait() 69 | close(data) 70 | }() 71 | 72 | toDelete := make(sort.Elements, 0) 73 | for e := range data { 74 | toDelete = append(toDelete, e) 75 | } 76 | 77 | if len(toDelete) == 0 { 78 | return fmt.Errorf("user not found: %s", deleteFieldRegexp) 79 | } 80 | 81 | if deleteHosts { 82 | for _, h := range toDelete.Hosts() { 83 | fmt.Println(h.Project) 84 | } 85 | return nil 86 | } 87 | 88 | // do not allow to delete more than 1 user 89 | if len(toDelete) > 1 { 90 | return fmt.Errorf("you don't want to use it as bulk function") 91 | } 92 | 93 | util.AskUser(fmt.Sprintf("Do you really want to delete user %q in %d gitlab(s) %v ?", 94 | deleteFieldRegexp, len(toDelete.Hosts()), toDelete.Hosts().Projects(common.Config.ShowAll))) 95 | 96 | deleted := make(chan interface{}) 97 | for _, v := range toDelete.Typed() { 98 | wg.Add(1) 99 | go deleteUser(v.Host, v.Struct.(*gitlab.User), wg, deleted) 100 | } 101 | 102 | go func() { 103 | wg.Wait() 104 | close(deleted) 105 | }() 106 | 107 | results, err := sort.FromChannel(deleted, &sort.Options{ 108 | OrderBy: []string{deleteBy}, 109 | StructType: gitlab.User{}, 110 | }) 111 | if err != nil { 112 | return err 113 | } 114 | 115 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', tabwriter.TabIndent) 116 | fmt.Fprintf(w, "COUNT\tUSER\tHOSTS\tCACHED\n") 117 | 118 | total := 0 119 | for _, v := range results { 120 | fmt.Fprintf(w, "[%d]\t%s\t%s\t[%s]\n", v.Count, v.Key, v.Elements.Hosts().Projects(common.Config.ShowAll), v.Cached) 121 | total++ 122 | } 123 | 124 | fmt.Fprintf(w, "Deleted: %d\nErrors: %d\n", total, len(wg.Errors())) 125 | 126 | w.Flush() 127 | 128 | for _, err := range wg.Errors() { 129 | hclog.L().Error(err.Err.Error()) 130 | } 131 | 132 | return nil 133 | 134 | } 135 | 136 | func deleteUser(h *client.Host, user *gitlab.User, wg *limiter.Limiter, data chan<- interface{}, 137 | options ...gitlab.RequestOptionFunc) { 138 | 139 | defer wg.Done() 140 | 141 | wg.Lock() 142 | resp, err := h.Client.Users.DeleteUser(user.ID, options...) 143 | if err != nil { 144 | wg.Error(h, err) 145 | wg.Unlock() 146 | return 147 | } 148 | wg.Unlock() 149 | 150 | data <- sort.Element{Host: h, Struct: user, Cached: resp.Header.Get("X-From-Cache") == "1"} 151 | } 152 | -------------------------------------------------------------------------------- /cmd/users/list.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "regexp" 7 | "text/tabwriter" 8 | 9 | "github.com/flant/glaball/pkg/client" 10 | "github.com/flant/glaball/pkg/limiter" 11 | "github.com/flant/glaball/pkg/sort/v2" 12 | "github.com/flant/glaball/pkg/util" 13 | 14 | "github.com/flant/glaball/cmd/common" 15 | 16 | "github.com/hashicorp/go-hclog" 17 | "github.com/spf13/cobra" 18 | "github.com/xanzy/go-gitlab" 19 | ) 20 | 21 | var ( 22 | listCount int 23 | groupBy, sortBy string 24 | orderBy []string 25 | 26 | listUsersOptions = gitlab.ListUsersOptions{ListOptions: gitlab.ListOptions{PerPage: 100}} 27 | ) 28 | 29 | func NewListCmd() *cobra.Command { 30 | cmd := &cobra.Command{ 31 | Use: "list", 32 | Short: "List users.", 33 | RunE: func(cmd *cobra.Command, args []string) error { 34 | return List() 35 | }, 36 | } 37 | 38 | cmd.Flags().Var(util.NewEnumValue(&groupBy, "name", "username", "email"), "group_by", 39 | "Return users grouped by id, name, username, fields.") 40 | 41 | cmd.Flags().Var(util.NewEnumValue(&sortBy, "asc", "desc"), "sort", 42 | "Return users sorted in asc or desc order. Default is desc") 43 | 44 | //"id", "name", "username", "email", "count" 45 | cmd.Flags().StringSliceVar(&orderBy, "order_by", []string{"count", userDefaultField}, 46 | "Return users ordered by id, name, username, created_at, or updated_at fields.") 47 | 48 | cmd.Flags().IntVar(&listCount, "count", 1, "Order by count") 49 | 50 | listUsersOptionsFlags(cmd, &listUsersOptions) 51 | 52 | return cmd 53 | } 54 | 55 | func listUsersOptionsFlags(cmd *cobra.Command, opt *gitlab.ListUsersOptions) { 56 | cmd.Flags().Var(util.NewBoolPtrValue(&opt.Active), "active", 57 | `In addition, you can filter users based on the states blocked and active. 58 | It does not support active=false or blocked=false. 59 | The list of billable users is the total number of users minus the blocked users.`) 60 | 61 | cmd.Flags().Var(util.NewBoolPtrValue(&opt.Blocked), "blocked", 62 | `In addition, you can filter users based on the states blocked and active. 63 | It does not support active=false or blocked=false. 64 | The list of billable users is the total number of users minus the blocked users.`) 65 | 66 | cmd.Flags().Var(util.NewBoolPtrValue(&opt.ExcludeInternal), "exclude_internal", 67 | "In addition, you can search for external users only with external=true. It does not support external=false.") 68 | 69 | // The options below are only available for admins. 70 | cmd.Flags().Var(util.NewStringPtrValue(&opt.Search), "search", 71 | "You can also search for users by name, username, primary email, or secondary email") 72 | 73 | cmd.Flags().Var(util.NewStringPtrValue(&opt.Username), "username", 74 | "In addition, you can lookup users by username. Username search is case insensitive") 75 | 76 | cmd.Flags().Var(util.NewStringPtrValue(&opt.ExternalUID), "extern_uid", 77 | "You can lookup users by external UID and provider") 78 | 79 | cmd.Flags().Var(util.NewStringPtrValue(&opt.Provider), "provider", 80 | "You can lookup users by external UID and provider") 81 | 82 | cmd.Flags().Var(util.NewTimePtrValue(&opt.CreatedBefore), "created_before", 83 | "You can search users by creation date time range") 84 | 85 | cmd.Flags().Var(util.NewTimePtrValue(&opt.CreatedAfter), "created_after", 86 | "You can search users by creation date time range") 87 | 88 | cmd.Flags().Var(util.NewEnumPtrValue(&opt.TwoFactor, "enabled", "disabled"), "two_factor", 89 | "Filter users by Two-factor authentication. Filter values are enabled or disabled. By default it returns all users") 90 | 91 | cmd.Flags().Var(util.NewBoolPtrValue(&opt.Admins), "admins", 92 | "Return only admin users. Default is false") 93 | 94 | cmd.Flags().Var(util.NewBoolPtrValue(&opt.External), "external", 95 | "Flags the user as external - true or false. Default is false") 96 | 97 | cmd.Flags().Var(util.NewBoolPtrValue(&opt.WithoutProjects), "without_projects", 98 | "Filter users without projects. Default is false, which means that all users are returned, with and without projects") 99 | 100 | cmd.Flags().Var(util.NewBoolPtrValue(&opt.WithCustomAttributes), "with_custom_attributes", 101 | "You can include the users’ custom attributes in the response. Default is false") 102 | } 103 | 104 | func List() error { 105 | if !sort.ValidOrderBy(orderBy, gitlab.User{}) { 106 | orderBy = append(orderBy, userDefaultField) 107 | } 108 | 109 | wg := common.Limiter 110 | data := make(chan interface{}) 111 | 112 | for _, h := range common.Client.Hosts { 113 | fmt.Printf("Fetching users from %s ...\n", h.URL) 114 | wg.Add(1) 115 | go listUsers(h, listUsersOptions, wg, data, common.Client.WithCache()) 116 | } 117 | 118 | go func() { 119 | wg.Wait() 120 | close(data) 121 | }() 122 | 123 | results, err := sort.FromChannel(data, &sort.Options{ 124 | OrderBy: orderBy, 125 | SortBy: sortBy, 126 | GroupBy: groupBy, 127 | StructType: gitlab.User{}, 128 | }) 129 | if err != nil { 130 | return err 131 | } 132 | 133 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', tabwriter.TabIndent) 134 | fmt.Fprintf(w, "COUNT\tUSER\tHOSTS\tCACHED\n") 135 | unique := 0 136 | total := 0 137 | 138 | for _, v := range results { 139 | if v.Count < listCount { 140 | continue 141 | } 142 | 143 | unique++ // todo 144 | total += v.Count //todo 145 | 146 | fmt.Fprintf(w, "[%d]\t%s\t%s\t[%s]\n", v.Count, v.Key, v.Elements.Hosts().Projects(common.Config.ShowAll), v.Cached) 147 | } 148 | 149 | fmt.Fprintf(w, "Unique: %d\nTotal: %d\nErrors: %d\n", unique, total, len(wg.Errors())) 150 | 151 | w.Flush() 152 | 153 | for _, err := range wg.Errors() { 154 | hclog.L().Error(err.Err.Error()) 155 | } 156 | 157 | return nil 158 | 159 | } 160 | 161 | func listUsers(h *client.Host, opt gitlab.ListUsersOptions, 162 | wg *limiter.Limiter, data chan<- interface{}, options ...gitlab.RequestOptionFunc) { 163 | 164 | defer wg.Done() 165 | 166 | wg.Lock() 167 | list, resp, err := h.Client.Users.ListUsers(&opt, options...) 168 | if err != nil { 169 | wg.Error(h, err) 170 | wg.Unlock() 171 | return 172 | } 173 | wg.Unlock() 174 | 175 | for _, v := range list { 176 | data <- sort.Element{Host: h, Struct: v, Cached: resp.Header.Get("X-From-Cache") == "1"} 177 | } 178 | 179 | if resp.NextPage > 0 { 180 | wg.Add(1) 181 | opt.Page = resp.NextPage 182 | go listUsers(h, opt, wg, data, options...) 183 | } 184 | } 185 | 186 | func listUsersSearch(h *client.Host, key string, value *regexp.Regexp, opt gitlab.ListUsersOptions, 187 | wg *limiter.Limiter, data chan<- interface{}, options ...gitlab.RequestOptionFunc) { 188 | 189 | defer wg.Done() 190 | 191 | wg.Lock() 192 | list, resp, err := h.Client.Users.ListUsers(&opt, options...) 193 | if err != nil { 194 | wg.Error(h, err) 195 | wg.Unlock() 196 | return 197 | } 198 | wg.Unlock() 199 | 200 | for _, v := range list { 201 | s, err := sort.ValidFieldValue([]string{key}, v) 202 | if err != nil { 203 | wg.Error(h, err) 204 | return 205 | } 206 | // This will panic if value is not a string 207 | if value.MatchString(s.(string)) { 208 | data <- sort.Element{Host: h, Struct: v, Cached: resp.Header.Get("X-From-Cache") == "1"} 209 | } 210 | } 211 | 212 | if resp.NextPage > 0 { 213 | wg.Add(1) 214 | opt.Page = resp.NextPage 215 | go listUsersSearch(h, key, value, opt, wg, data, options...) 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /cmd/users/modify.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "regexp" 7 | "text/tabwriter" 8 | 9 | "github.com/flant/glaball/pkg/client" 10 | "github.com/flant/glaball/pkg/limiter" 11 | "github.com/flant/glaball/pkg/sort/v2" 12 | "github.com/flant/glaball/pkg/util" 13 | 14 | "github.com/flant/glaball/cmd/common" 15 | 16 | "github.com/hashicorp/go-hclog" 17 | "github.com/spf13/cobra" 18 | "github.com/xanzy/go-gitlab" 19 | ) 20 | 21 | var ( 22 | modifyOpt = gitlab.ModifyUserOptions{} 23 | modifyBy string 24 | modifyFieldRegexp *regexp.Regexp 25 | 26 | listHosts bool 27 | ) 28 | 29 | func NewModifyCmd() *cobra.Command { 30 | cmd := &cobra.Command{ 31 | Use: "modify --by=[email|username|name] [regexp]", 32 | Short: "Modifies an existing user", 33 | Long: "Modifies an existing user. Only administrators can change attributes of a user.", 34 | Args: cobra.ExactArgs(1), 35 | RunE: func(cmd *cobra.Command, args []string) error { 36 | re, err := regexp.Compile(args[0]) 37 | if err != nil { 38 | return err 39 | } 40 | modifyFieldRegexp = re 41 | return Modify() 42 | }, 43 | } 44 | 45 | cmd.Flags().Var(util.NewEnumValue(&modifyBy, "email", "username", "name"), "by", "Search user you want to modify") 46 | cmd.MarkFlagRequired("by") 47 | 48 | cmd.Flags().BoolVar(&listHosts, "hosts", false, "List hosts where user exists") 49 | 50 | // ModifyUserOptions 51 | cmd.Flags().Var(util.NewStringPtrValue(&modifyOpt.Email), "email", "Email.") 52 | cmd.Flags().Var(util.NewStringPtrValue(&modifyOpt.Password), "password", "Password.") 53 | cmd.Flags().Var(util.NewStringPtrValue(&modifyOpt.Username), "username", "Username") 54 | cmd.Flags().Var(util.NewStringPtrValue(&modifyOpt.Name), "name", "Name") 55 | cmd.Flags().Var(util.NewStringPtrValue(&modifyOpt.Skype), "skype", "Skype ID") 56 | cmd.Flags().Var(util.NewStringPtrValue(&modifyOpt.Linkedin), "linkedin", "LinkedIn") 57 | cmd.Flags().Var(util.NewStringPtrValue(&modifyOpt.Twitter), "twitter", "Twitter account.") 58 | cmd.Flags().Var(util.NewStringPtrValue(&modifyOpt.WebsiteURL), "website_url", "Website URL") 59 | cmd.Flags().Var(util.NewStringPtrValue(&modifyOpt.Organization), "organization", "Organization name") 60 | cmd.Flags().Var(util.NewBoolPtrValue(&modifyOpt.Admin), "admin", "User is admin - true or false (default).") 61 | cmd.Flags().Var(util.NewBoolPtrValue(&modifyOpt.CanCreateGroup), "can_create_group", 62 | "User can create groups - true or false.") 63 | cmd.Flags().Var(util.NewBoolPtrValue(&modifyOpt.SkipReconfirmation), "skip_reconfirmation", 64 | "Skip reconfirmation - true or false (default).") 65 | cmd.Flags().Var(util.NewBoolPtrValue(&modifyOpt.External), "external", 66 | "Flags the user as external - true or false. Default is false") 67 | cmd.Flags().Var(util.NewBoolPtrValue(&modifyOpt.PrivateProfile), "private_profile", 68 | "User’s profile is private - true, false (default), or null (is converted to false).") 69 | cmd.Flags().Var(util.NewStringPtrValue(&modifyOpt.Note), "note", "Admin notes for this user.") 70 | 71 | return cmd 72 | } 73 | 74 | func Modify() error { 75 | wg := common.Limiter 76 | data := make(chan interface{}) 77 | 78 | fmt.Printf("Searching for user %q...\n", modifyFieldRegexp) 79 | for _, h := range common.Client.Hosts { 80 | wg.Add(1) 81 | go listUsersSearch(h, modifyBy, modifyFieldRegexp, gitlab.ListUsersOptions{ 82 | ListOptions: gitlab.ListOptions{ 83 | PerPage: 100, 84 | }, 85 | }, wg, data, common.Client.WithNoCache()) 86 | } 87 | 88 | go func() { 89 | wg.Wait() 90 | close(data) 91 | }() 92 | 93 | toModify := make(sort.Elements, 0) 94 | for e := range data { 95 | toModify = append(toModify, e) 96 | } 97 | 98 | if len(toModify) == 0 { 99 | return fmt.Errorf("user not found: %s", modifyFieldRegexp) 100 | } 101 | 102 | if listHosts { 103 | for _, h := range toModify.Hosts() { 104 | fmt.Println(h.Project) 105 | } 106 | return nil 107 | } 108 | 109 | util.AskUser(fmt.Sprintf("Do you really want to modify %d users %q in %d gitlab(s) %v ?", 110 | len(toModify), modifyFieldRegexp, len(toModify.Hosts()), toModify.Hosts().Projects(common.Config.ShowAll))) 111 | 112 | modified := make(chan interface{}) 113 | for _, v := range toModify.Typed() { 114 | wg.Add(1) 115 | go modifyUser(v.Host, v.Struct.(*gitlab.User).ID, modifyOpt, wg, modified) 116 | } 117 | 118 | go func() { 119 | wg.Wait() 120 | close(modified) 121 | }() 122 | 123 | results, err := sort.FromChannel(modified, &sort.Options{ 124 | OrderBy: []string{modifyBy}, 125 | StructType: gitlab.User{}, 126 | }) 127 | if err != nil { 128 | return err 129 | } 130 | 131 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', tabwriter.TabIndent) 132 | fmt.Fprintf(w, "COUNT\tUSER\tHOSTS\tCACHED\n") 133 | 134 | total := 0 135 | for _, v := range results { 136 | fmt.Fprintf(w, "[%d]\t%s\t%s\t[%s]\n", v.Count, v.Key, v.Elements.Hosts().Projects(common.Config.ShowAll), v.Cached) 137 | total++ 138 | } 139 | 140 | fmt.Fprintf(w, "Modified: %d\nErrors: %d\n", total, len(wg.Errors())) 141 | 142 | w.Flush() 143 | 144 | for _, err := range wg.Errors() { 145 | hclog.L().Error(err.Err.Error()) 146 | } 147 | 148 | return nil 149 | 150 | } 151 | 152 | func modifyUser(h *client.Host, id int, opt gitlab.ModifyUserOptions, wg *limiter.Limiter, data chan<- interface{}, 153 | options ...gitlab.RequestOptionFunc) { 154 | 155 | defer wg.Done() 156 | 157 | wg.Lock() 158 | user, resp, err := h.Client.Users.ModifyUser(id, &opt, options...) 159 | if err != nil { 160 | wg.Error(h, err) 161 | wg.Unlock() 162 | return 163 | } 164 | wg.Unlock() 165 | 166 | data <- sort.Element{Host: h, Struct: user, Cached: resp.Header.Get("X-From-Cache") == "1"} 167 | } 168 | -------------------------------------------------------------------------------- /cmd/users/search.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "regexp" 7 | "text/tabwriter" 8 | 9 | "github.com/flant/glaball/pkg/sort/v2" 10 | "github.com/flant/glaball/pkg/util" 11 | 12 | "github.com/flant/glaball/cmd/common" 13 | 14 | "github.com/hashicorp/go-hclog" 15 | "github.com/spf13/cobra" 16 | "github.com/xanzy/go-gitlab" 17 | ) 18 | 19 | var ( 20 | searchBy string 21 | searchFieldRegexp *regexp.Regexp 22 | ) 23 | 24 | func NewSearchCmd() *cobra.Command { 25 | cmd := &cobra.Command{ 26 | Use: "search --by=[email|username|name] [regexp]", 27 | Short: "Search for user", 28 | Args: cobra.ExactArgs(1), 29 | RunE: func(cmd *cobra.Command, args []string) error { 30 | re, err := regexp.Compile(args[0]) 31 | if err != nil { 32 | return err 33 | } 34 | searchFieldRegexp = re 35 | return Search() 36 | }, 37 | } 38 | 39 | cmd.Flags().Var(util.NewEnumValue(&searchBy, "email", "username", "name"), "by", "Search field") 40 | cmd.MarkFlagRequired("by") 41 | 42 | listUsersOptionsFlags(cmd, &listUsersOptions) 43 | 44 | return cmd 45 | } 46 | 47 | func Search() error { 48 | wg := common.Limiter 49 | data := make(chan interface{}) 50 | 51 | fmt.Printf("Searching for user %s %q...\n", searchBy, searchFieldRegexp) 52 | for _, h := range common.Client.Hosts { 53 | wg.Add(1) 54 | go listUsersSearch(h, searchBy, searchFieldRegexp, listUsersOptions, wg, data, common.Client.WithCache()) 55 | } 56 | 57 | go func() { 58 | wg.Wait() 59 | close(data) 60 | }() 61 | 62 | results, err := sort.FromChannel(data, &sort.Options{ 63 | OrderBy: []string{searchBy}, 64 | StructType: gitlab.User{}, 65 | }) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', tabwriter.TabIndent) 71 | fmt.Fprintf(w, "COUNT\tUSER\tHOSTS\tCACHED\n") 72 | 73 | total := 0 74 | for _, v := range results { 75 | fmt.Fprintf(w, "[%d]\t%s\t%s\t[%s]\n", v.Count, v.Key, v.Elements.Hosts().Projects(common.Config.ShowAll), v.Cached) 76 | total++ 77 | } 78 | 79 | fmt.Fprintf(w, "Found: %d\nErrors: %d\n", total, len(wg.Errors())) 80 | 81 | w.Flush() 82 | 83 | for _, err := range wg.Errors() { 84 | hclog.L().Error(err.Err.Error()) 85 | } 86 | 87 | return nil 88 | 89 | } 90 | -------------------------------------------------------------------------------- /cmd/users/search_test.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "regexp" 8 | "testing" 9 | 10 | "github.com/flant/glaball/pkg/limiter" 11 | "github.com/flant/glaball/pkg/sort/v2" 12 | 13 | "github.com/flant/glaball/cmd/common" 14 | 15 | "github.com/stretchr/testify/assert" 16 | "github.com/xanzy/go-gitlab" 17 | ) 18 | 19 | func TestSearch(t *testing.T) { 20 | mux, server, cli := common.Setup(t) 21 | defer common.Teardown(server) 22 | 23 | mux.HandleFunc("/api/v4/users", func(w http.ResponseWriter, r *http.Request) { 24 | if got := r.Method; got != http.MethodGet { 25 | t.Errorf("Request method: %s, want %s", got, http.MethodGet) 26 | } 27 | fmt.Fprint(w, TestData) 28 | }) 29 | 30 | wg := limiter.NewLimiter(limiter.DefaultLimit) 31 | data := make(chan interface{}) 32 | 33 | searchBy := "username" 34 | searchFieldValue := regexp.MustCompile("testuser2") 35 | 36 | fmt.Printf("Searching for user %q...\n", searchFieldValue) 37 | for _, h := range cli.Hosts { 38 | wg.Add(1) 39 | go listUsersSearch(h, searchBy, searchFieldValue, gitlab.ListUsersOptions{ 40 | ListOptions: gitlab.ListOptions{ 41 | PerPage: 100, 42 | }, 43 | }, wg, data, cli.WithCache()) 44 | } 45 | 46 | go func() { 47 | wg.Wait() 48 | close(data) 49 | }() 50 | 51 | results, err := sort.FromChannel(data, &sort.Options{ 52 | OrderBy: []string{searchBy}, 53 | StructType: gitlab.User{}, 54 | }) 55 | assert.NoError(t, err) 56 | 57 | var user gitlab.User 58 | 59 | err = json.Unmarshal([]byte(`{ 60 | "id": 122, 61 | "username": "testuser2", 62 | "name": "Test User 2", 63 | "state": "active", 64 | "avatar_url": "", 65 | "web_url": "https://gitlab.example.com/testuser2", 66 | "created_at": "2022-04-21T15:21:23.810+00:00", 67 | "bio": "", 68 | "location": "", 69 | "public_email": "", 70 | "skype": "", 71 | "linkedin": "", 72 | "twitter": "", 73 | "website_url": "", 74 | "organization": "", 75 | "job_title": "", 76 | "pronouns": "", 77 | "bot": false, 78 | "work_information": null, 79 | "followers": 0, 80 | "following": 0, 81 | "local_time": "5:45 PM", 82 | "last_sign_in_at": "2022-04-22T15:50:57.801+00:00", 83 | "confirmed_at": "2022-04-21T15:21:23.534+00:00", 84 | "last_activity_on": "2022-04-29", 85 | "email": "testuser2@example.com", 86 | "theme_id": 11, 87 | "color_scheme_id": 1, 88 | "projects_limit": 100000, 89 | "current_sign_in_at": "2022-04-27T07:54:55.035+00:00", 90 | "identities": [], 91 | "can_create_group": false, 92 | "can_create_project": true, 93 | "two_factor_enabled": true, 94 | "external": false, 95 | "private_profile": false, 96 | "commit_email": "testuser2@example.com", 97 | "is_admin": false, 98 | "note": "" 99 | }`), &user) 100 | 101 | assert.NoError(t, err) 102 | 103 | expected := []sort.Result{ 104 | { 105 | Count: 1, 106 | Key: searchFieldValue.String(), 107 | Elements: sort.Elements{sort.Element{Host: cli.Hosts[1], Struct: &user, Cached: false}}, 108 | Cached: false, 109 | }, 110 | { 111 | Count: 1, 112 | Key: searchFieldValue.String(), 113 | Elements: sort.Elements{sort.Element{Host: cli.Hosts[0], Struct: &user, Cached: false}}, 114 | Cached: false, 115 | }} 116 | 117 | assert.NotNil(t, results) 118 | assert.Equal(t, expected, results) 119 | 120 | } 121 | -------------------------------------------------------------------------------- /cmd/users/users.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | const ( 8 | userDefaultField = "username" 9 | ) 10 | 11 | func NewCmd() *cobra.Command { 12 | cmd := &cobra.Command{ 13 | Use: "users", 14 | Short: "Users API", 15 | } 16 | 17 | cmd.AddCommand( 18 | NewBlockCmd(), 19 | NewCreateCmd(), 20 | NewDeleteCmd(), 21 | NewListCmd(), 22 | NewModifyCmd(), 23 | NewSearchCmd(), 24 | NewWhoamiCmd(), 25 | ) 26 | 27 | return cmd 28 | } 29 | -------------------------------------------------------------------------------- /cmd/users/users_test.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | const TestData = `[ 4 | { 5 | "id": 123, 6 | "username": "testuser1", 7 | "name": "Test User 1", 8 | "state": "active", 9 | "avatar_url": "", 10 | "web_url": "https://gitlab.example.com/testuser1", 11 | "created_at": "2022-04-25T18:07:15.377+00:00", 12 | "bio": "", 13 | "location": null, 14 | "public_email": null, 15 | "skype": "", 16 | "linkedin": "", 17 | "twitter": "", 18 | "website_url": "", 19 | "organization": null, 20 | "job_title": "", 21 | "pronouns": null, 22 | "bot": false, 23 | "work_information": null, 24 | "followers": 0, 25 | "following": 0, 26 | "local_time": null, 27 | "last_sign_in_at": "2022-04-27T10:46:55.582+00:00", 28 | "confirmed_at": "2022-04-25T18:07:15.056+00:00", 29 | "last_activity_on": "2022-04-27", 30 | "email": "testuser1@example.com", 31 | "theme_id": 1, 32 | "color_scheme_id": 1, 33 | "projects_limit": 100000, 34 | "current_sign_in_at": "2022-04-27T10:46:55.582+00:00", 35 | "identities": [], 36 | "can_create_group": false, 37 | "can_create_project": true, 38 | "two_factor_enabled": true, 39 | "external": false, 40 | "private_profile": false, 41 | "commit_email": "testuser1@example.com", 42 | "is_admin": false, 43 | "note": "" 44 | }, { 45 | "id": 122, 46 | "username": "testuser2", 47 | "name": "Test User 2", 48 | "state": "active", 49 | "avatar_url": "", 50 | "web_url": "https://gitlab.example.com/testuser2", 51 | "created_at": "2022-04-21T15:21:23.810+00:00", 52 | "bio": "", 53 | "location": "", 54 | "public_email": "", 55 | "skype": "", 56 | "linkedin": "", 57 | "twitter": "", 58 | "website_url": "", 59 | "organization": "", 60 | "job_title": "", 61 | "pronouns": "", 62 | "bot": false, 63 | "work_information": null, 64 | "followers": 0, 65 | "following": 0, 66 | "local_time": "5:45 PM", 67 | "last_sign_in_at": "2022-04-22T15:50:57.801+00:00", 68 | "confirmed_at": "2022-04-21T15:21:23.534+00:00", 69 | "last_activity_on": "2022-04-29", 70 | "email": "testuser2@example.com", 71 | "theme_id": 11, 72 | "color_scheme_id": 1, 73 | "projects_limit": 100000, 74 | "current_sign_in_at": "2022-04-27T07:54:55.035+00:00", 75 | "identities": [], 76 | "can_create_group": false, 77 | "can_create_project": true, 78 | "two_factor_enabled": true, 79 | "external": false, 80 | "private_profile": false, 81 | "commit_email": "testuser2@example.com", 82 | "is_admin": false, 83 | "note": "" 84 | } 85 | ]` 86 | -------------------------------------------------------------------------------- /cmd/users/whoami.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "text/tabwriter" 7 | 8 | "github.com/flant/glaball/pkg/client" 9 | "github.com/flant/glaball/pkg/limiter" 10 | "github.com/flant/glaball/pkg/sort/v2" 11 | 12 | "github.com/flant/glaball/cmd/common" 13 | 14 | "github.com/hashicorp/go-hclog" 15 | "github.com/spf13/cobra" 16 | "github.com/xanzy/go-gitlab" 17 | ) 18 | 19 | func NewWhoamiCmd() *cobra.Command { 20 | cmd := &cobra.Command{ 21 | Use: "whoami", 22 | Short: "Current API user", 23 | Long: "Get info about the user whose token is used for API calls.", 24 | RunE: func(cmd *cobra.Command, args []string) error { 25 | return Whoami() 26 | }, 27 | } 28 | 29 | return cmd 30 | } 31 | 32 | func Whoami() error { 33 | wg := common.Limiter 34 | data := make(chan interface{}) 35 | for _, h := range common.Client.Hosts { 36 | fmt.Printf("Getting current user info from %s ...\n", h.URL) 37 | wg.Add(1) 38 | go currentUser(h, wg, data, common.Client.WithNoCache()) 39 | } 40 | 41 | go func() { 42 | wg.Wait() 43 | close(data) 44 | }() 45 | 46 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', tabwriter.TabIndent) 47 | fmt.Fprintf(w, "COUNT\tUSER\tHOSTS\tCACHED\n") 48 | total := 0 49 | 50 | results, err := sort.FromChannel(data, &sort.Options{ 51 | OrderBy: []string{"username"}, 52 | SortBy: "desc", 53 | GroupBy: "", 54 | StructType: gitlab.User{}, 55 | }) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | for _, v := range results { 61 | total += v.Count //todo 62 | 63 | fmt.Fprintf(w, "[%d]\t%s\t%s\t[%s]\n", v.Count, v.Key, v.Elements.Hosts().Projects(common.Config.ShowAll), v.Cached) 64 | } 65 | 66 | fmt.Fprintf(w, "Total: %d\nErrors: %d\n", total, len(wg.Errors())) 67 | 68 | w.Flush() 69 | 70 | for _, err := range wg.Errors() { 71 | hclog.L().Error(err.Err.Error()) 72 | } 73 | 74 | return nil 75 | } 76 | 77 | func currentUser(h *client.Host, wg *limiter.Limiter, data chan<- interface{}, options ...gitlab.RequestOptionFunc) { 78 | defer wg.Done() 79 | 80 | wg.Lock() 81 | user, resp, err := h.Client.Users.CurrentUser(options...) 82 | if err != nil { 83 | wg.Error(h, err) 84 | wg.Unlock() 85 | return 86 | } 87 | wg.Unlock() 88 | 89 | data <- sort.Element{Host: h, Struct: user, Cached: resp.Header.Get("X-From-Cache") == "1"} 90 | } 91 | -------------------------------------------------------------------------------- /cmd/versions/main.go: -------------------------------------------------------------------------------- 1 | package versions 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/xml" 6 | "fmt" 7 | "net/http" 8 | "os" 9 | "strings" 10 | "text/tabwriter" 11 | 12 | "github.com/flant/glaball/pkg/client" 13 | "github.com/flant/glaball/pkg/limiter" 14 | "github.com/flant/glaball/pkg/sort/v2" 15 | 16 | "github.com/flant/glaball/cmd/common" 17 | 18 | "github.com/hashicorp/go-cleanhttp" 19 | "github.com/hashicorp/go-hclog" 20 | "github.com/spf13/cobra" 21 | "github.com/xanzy/go-gitlab" 22 | ) 23 | 24 | var ( 25 | httpClient = cleanhttp.DefaultPooledClient() 26 | ) 27 | 28 | type VersionCheck struct { 29 | Version string `json:"version"` 30 | CheckResult string `json:"check_result"` 31 | } 32 | 33 | type VersionCheckResponse struct { 34 | XmlName xml.Name `xml:"svg"` 35 | Text string `xml:"text"` 36 | } 37 | 38 | func NewCmd() *cobra.Command { 39 | cmd := &cobra.Command{ 40 | Use: "versions", 41 | Short: "Retrieve version information for GitLab instances", 42 | Long: "", 43 | RunE: func(cmd *cobra.Command, args []string) error { 44 | return Versions() 45 | }, 46 | } 47 | 48 | return cmd 49 | } 50 | 51 | func Versions() error { 52 | wg := common.Limiter 53 | data := make(chan interface{}) 54 | for _, h := range common.Client.Hosts { 55 | fmt.Printf("Getting current version info from %s ...\n", h.URL) 56 | wg.Add(1) 57 | go currentVersion(h, wg, data, common.Client.WithNoCache()) 58 | } 59 | 60 | go func() { 61 | wg.Wait() 62 | close(data) 63 | }() 64 | 65 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', tabwriter.TabIndent) 66 | fmt.Fprintf(w, "HOST\tURL\tVERSION\tSTATUS\n") 67 | total := 0 68 | 69 | results, err := sort.FromChannel(data, &sort.Options{ 70 | OrderBy: []string{"host", "version"}, 71 | SortBy: "asc", 72 | GroupBy: "", 73 | StructType: VersionCheck{}, 74 | }) 75 | if err != nil { 76 | return err 77 | } 78 | 79 | for _, v := range results { 80 | total++ 81 | elem := v.Elements.Typed()[0] 82 | fmt.Fprintf(w, "[%s]\t%s\t%s\t[%s]\n", elem.Host.Project, elem.Host.URL, elem.Struct.(VersionCheck).Version, elem.Struct.(VersionCheck).CheckResult) 83 | } 84 | 85 | fmt.Fprintf(w, "Total: %d\nErrors: %d\n", total, len(wg.Errors())) 86 | 87 | w.Flush() 88 | 89 | for _, err := range wg.Errors() { 90 | hclog.L().Error(err.Err.Error()) 91 | } 92 | 93 | return nil 94 | } 95 | 96 | func currentVersion(h *client.Host, wg *limiter.Limiter, data chan<- interface{}, options ...gitlab.RequestOptionFunc) { 97 | defer wg.Done() 98 | 99 | wg.Lock() 100 | version, resp, err := h.Client.Version.GetVersion(options...) 101 | if err != nil { 102 | wg.Error(h, err) 103 | wg.Unlock() 104 | return 105 | } 106 | check, err := checkVersion(h, version) 107 | if err != nil { 108 | wg.Error(h, err) 109 | wg.Unlock() 110 | return 111 | } 112 | wg.Unlock() 113 | 114 | data <- sort.Element{Host: h, Struct: VersionCheck{version.Version, check}, Cached: resp.Header.Get("X-From-Cache") == "1"} 115 | } 116 | 117 | func checkVersion(h *client.Host, version *gitlab.Version) (string, error) { 118 | b64version := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("{\"version\": \"%s\"}", version.Version))) 119 | url := fmt.Sprintf("https://version.gitlab.com/check.svg?gitlab_info=%s", b64version) 120 | 121 | req, err := http.NewRequest("GET", url, nil) 122 | if err != nil { 123 | return "", err 124 | } 125 | req.Header.Set("Referer", fmt.Sprintf("%s/help", h.URL)) 126 | 127 | resp, err := httpClient.Do(req) 128 | if err != nil { 129 | return "", err 130 | } 131 | 132 | dec := xml.NewDecoder(resp.Body) 133 | 134 | var versionCheckResponse VersionCheckResponse 135 | if err := dec.Decode(&versionCheckResponse); err != nil { 136 | return "", err 137 | } 138 | 139 | return strings.ToLower(versionCheckResponse.Text), nil 140 | } 141 | -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | # cache: 2 | # enabled: true 3 | # size: 100MB 4 | # compression: true 5 | # ttl: 24h 6 | 7 | # rate_limiter: 8 | # enabled: false 9 | 10 | # filter: ".*" 11 | 12 | # threads: 100 13 | 14 | # all: false 15 | 16 | hosts: 17 | main: 18 | example-project: 19 | primary: 20 | url: https://gitlab-primary.example.com 21 | token: api_token 22 | # secondary: 23 | # url: https://gitlab-secondary.example.com 24 | # ip: 127.0.0.1 25 | # token: api_token 26 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/flant/glaball 2 | 3 | go 1.23 4 | 5 | require ( 6 | dario.cat/mergo v1.0.1 7 | github.com/ahmetb/go-linq v3.0.0+incompatible 8 | github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b 9 | github.com/armon/go-radix v1.0.0 10 | github.com/gofri/go-github-ratelimit v1.1.0 11 | github.com/google/go-github/v66 v66.0.0 12 | github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 13 | github.com/hashicorp/go-cleanhttp v0.5.2 14 | github.com/hashicorp/go-hclog v1.6.3 15 | github.com/hashicorp/go-retryablehttp v0.7.7 16 | github.com/jmoiron/sqlx v1.4.0 17 | github.com/peterbourgon/diskv v2.0.1+incompatible 18 | github.com/spf13/cobra v1.8.1 19 | github.com/spf13/viper v1.19.0 20 | github.com/stretchr/testify v1.9.0 21 | github.com/xanzy/go-gitlab v0.114.0 22 | gopkg.in/yaml.v3 v3.0.1 23 | ) 24 | 25 | require ( 26 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 27 | github.com/fatih/color v1.16.0 // indirect 28 | github.com/fsnotify/fsnotify v1.7.0 // indirect 29 | github.com/golang/protobuf v1.5.3 // indirect 30 | github.com/google/btree v1.1.3 // indirect 31 | github.com/google/go-querystring v1.1.0 // indirect 32 | github.com/hashicorp/hcl v1.0.0 // indirect 33 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 34 | github.com/magiconair/properties v1.8.7 // indirect 35 | github.com/mattn/go-colorable v0.1.13 // indirect 36 | github.com/mattn/go-isatty v0.0.20 // indirect 37 | github.com/mitchellh/mapstructure v1.5.0 // indirect 38 | github.com/pelletier/go-toml/v2 v2.2.2 // indirect 39 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 40 | github.com/sagikazarmark/locafero v0.4.0 // indirect 41 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 42 | github.com/sourcegraph/conc v0.3.0 // indirect 43 | github.com/spf13/afero v1.11.0 // indirect 44 | github.com/spf13/cast v1.6.0 // indirect 45 | github.com/spf13/pflag v1.0.5 // indirect 46 | github.com/subosito/gotenv v1.6.0 // indirect 47 | go.uber.org/atomic v1.9.0 // indirect 48 | go.uber.org/multierr v1.9.0 // indirect 49 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect 50 | golang.org/x/oauth2 v0.18.0 // indirect 51 | golang.org/x/sys v0.20.0 // indirect 52 | golang.org/x/text v0.14.0 // indirect 53 | golang.org/x/time v0.5.0 // indirect 54 | google.golang.org/appengine v1.6.8 // indirect 55 | google.golang.org/protobuf v1.33.0 // indirect 56 | gopkg.in/ini.v1 v1.67.0 // indirect 57 | ) 58 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= 2 | dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 4 | github.com/ahmetb/go-linq v3.0.0+incompatible h1:qQkjjOXKrKOTy83X8OpRmnKflXKQIL/mC/gMVVDMhOA= 5 | github.com/ahmetb/go-linq v3.0.0+incompatible/go.mod h1:PFffvbdbtw+QTB0WKRP0cNht7vnCfnGlEpak/DVg5cY= 6 | github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0= 7 | github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= 8 | github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= 9 | github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= 10 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 14 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= 16 | github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 17 | github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 18 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 19 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 20 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 21 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 22 | github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= 23 | github.com/gofri/go-github-ratelimit v1.1.0 h1:ijQ2bcv5pjZXNil5FiwglCg8wc9s8EgjTmNkqjw8nuk= 24 | github.com/gofri/go-github-ratelimit v1.1.0/go.mod h1:OnCi5gV+hAG/LMR7llGhU7yHt44se9sYgKPnafoL7RY= 25 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 26 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 27 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 28 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 29 | github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= 30 | github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= 31 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 32 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 33 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 34 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 35 | github.com/google/go-github/v66 v66.0.0 h1:ADJsaXj9UotwdgK8/iFZtv7MLc8E8WBl62WLd/D/9+M= 36 | github.com/google/go-github/v66 v66.0.0/go.mod h1:+4SO9Zkuyf8ytMj0csN1NR/5OTR+MfqPp8P8dVlcvY4= 37 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 38 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 39 | github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= 40 | github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= 41 | github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 42 | github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 43 | github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 44 | github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= 45 | github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= 46 | github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= 47 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 48 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 49 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 50 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 51 | github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= 52 | github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= 53 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 54 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 55 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 56 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 57 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 58 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= 59 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 60 | github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 61 | github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= 62 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 63 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 64 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 65 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 66 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 67 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 68 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 69 | github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 70 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 71 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 72 | github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= 73 | github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= 74 | github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= 75 | github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= 76 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 77 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 78 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 79 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 80 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 81 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 82 | github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= 83 | github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= 84 | github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= 85 | github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= 86 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 87 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 88 | github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= 89 | github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= 90 | github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= 91 | github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 92 | github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= 93 | github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 94 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 95 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 96 | github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= 97 | github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= 98 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 99 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 100 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 101 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 102 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 103 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 104 | github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= 105 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 106 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 107 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 108 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 109 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 110 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 111 | github.com/xanzy/go-gitlab v0.114.0 h1:0wQr/KBckwrZPfEMjRqpUz0HmsKKON9UhCYv9KDy19M= 112 | github.com/xanzy/go-gitlab v0.114.0/go.mod h1:wKNKh3GkYDMOsGmnfuX+ITCmDuSDWFO0G+C4AygL9RY= 113 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 114 | go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= 115 | go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 116 | go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= 117 | go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= 118 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 119 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 120 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= 121 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= 122 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 123 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 124 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 125 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 126 | golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI= 127 | golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8= 128 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 129 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 130 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 131 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 132 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 133 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 134 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 135 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 136 | golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 137 | golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 138 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 139 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 140 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 141 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 142 | golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= 143 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 144 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 145 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 146 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 147 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 148 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 149 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 150 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 151 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 152 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 153 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 154 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 155 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 156 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 157 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 158 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 159 | google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= 160 | google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= 161 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 162 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 163 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 164 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 165 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 166 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 167 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 168 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 169 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 170 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 171 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 172 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 173 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "time" 6 | 7 | gconfig "github.com/flant/glaball/pkg/config" 8 | "github.com/flant/glaball/pkg/limiter" 9 | 10 | "github.com/flant/glaball/cmd/cache" 11 | "github.com/flant/glaball/cmd/common" 12 | "github.com/flant/glaball/cmd/config" 13 | "github.com/flant/glaball/cmd/info" 14 | "github.com/flant/glaball/cmd/projects" 15 | "github.com/flant/glaball/cmd/users" 16 | "github.com/flant/glaball/cmd/versions" 17 | 18 | "github.com/hashicorp/go-hclog" 19 | "github.com/spf13/cobra" 20 | "github.com/spf13/viper" 21 | ) 22 | 23 | var ( 24 | cfgFile string 25 | 26 | logLevel string // "debug", "info", "warn", "error", "off" 27 | update bool 28 | verbose bool 29 | 30 | rootCmd = &cobra.Command{ 31 | Use: gconfig.ApplicationName, 32 | Short: "Gitlab bulk administration tool", 33 | Long: ``, 34 | SilenceErrors: false, 35 | SilenceUsage: true, 36 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { 37 | if verbose { 38 | logLevel = "debug" 39 | } 40 | 41 | if err := setLogLevel(logLevel); err != nil { 42 | return err 43 | } 44 | 45 | if update { 46 | viper.Set("cache.ttl", time.Duration(0)) 47 | } 48 | 49 | if err := common.Init(); err != nil { 50 | return err 51 | } 52 | 53 | return nil 54 | }, 55 | RunE: func(cmd *cobra.Command, args []string) error { 56 | return cmd.Help() 57 | }, 58 | } 59 | ) 60 | 61 | func Execute() { 62 | if err := rootCmd.Execute(); err != nil { 63 | hclog.L().Error(err.Error()) 64 | os.Exit(1) 65 | } 66 | } 67 | 68 | func main() { 69 | rootCmd.Execute() 70 | } 71 | 72 | func init() { 73 | cobra.OnInitialize(initConfig) 74 | 75 | rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", 76 | "Path to the configuration file. (default \"$HOME/.config/glaball/config.yaml\")") 77 | 78 | rootCmd.PersistentFlags().Int("threads", limiter.DefaultLimit, 79 | "Number of concurrent processes. (default: one process for each Gitlab instances in config file)") 80 | 81 | rootCmd.PersistentFlags().Duration("ttl", time.Duration(time.Hour*24), 82 | "Override cache TTL set in config file") 83 | 84 | rootCmd.PersistentFlags().StringP("filter", "f", ".*", "Select Gitlab(s) by regexp filter") 85 | 86 | rootCmd.PersistentFlags().BoolP("all", "a", false, "Show all hosts in grouped output") 87 | 88 | rootCmd.PersistentFlags().StringVar(&logLevel, "log_level", "info", 89 | "Only log messages with the given severity or above. Valid levels: [debug, info, warn, error, off]") 90 | 91 | rootCmd.PersistentFlags().BoolVarP(&update, "update", "u", false, "Refresh cache") 92 | 93 | rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Verbose output") 94 | 95 | rootCmd.AddCommand( 96 | cache.NewCmd(), 97 | config.NewCmd(), 98 | info.NewCmd(), 99 | projects.NewCmd(), 100 | users.NewCmd(), 101 | users.NewWhoamiCmd(), 102 | versions.NewCmd(), 103 | ) 104 | } 105 | 106 | func initConfig() { 107 | if cfgFile != "" { 108 | // Use config file from the flag. 109 | viper.SetConfigFile(cfgFile) 110 | } else { 111 | // Search config in default directory 112 | configDir, _ := gconfig.DefaultConfigDir() 113 | viper.AddConfigPath(configDir) 114 | viper.SetConfigType("yaml") 115 | viper.SetConfigName("config.yaml") 116 | } 117 | 118 | viper.SetDefault("cache.enabled", true) 119 | viper.SetDefault("cache.size", gconfig.DefaultCacheSize) 120 | viper.SetDefault("cache.compression", true) 121 | viper.BindPFlag("cache.ttl", rootCmd.Flags().Lookup("ttl")) 122 | 123 | viper.BindPFlag("filter", rootCmd.Flags().Lookup("filter")) 124 | 125 | viper.BindPFlag("all", rootCmd.Flags().Lookup("all")) 126 | 127 | viper.BindPFlag("threads", rootCmd.Flags().Lookup("threads")) 128 | 129 | viper.AutomaticEnv() 130 | 131 | if err := viper.ReadInConfig(); err == nil { 132 | hclog.L().Debug("Using config file", "config", viper.ConfigFileUsed()) 133 | } 134 | 135 | } 136 | 137 | func setLogLevel(logLevel string) error { 138 | options := hclog.LoggerOptions{ 139 | Level: hclog.LevelFromString(logLevel), 140 | JSONFormat: false, 141 | IncludeLocation: false, 142 | DisableTime: true, 143 | Color: hclog.AutoColor, 144 | IndependentLevels: false, 145 | } 146 | 147 | hclog.SetDefault(hclog.New(&options)) 148 | 149 | return nil 150 | } 151 | -------------------------------------------------------------------------------- /pkg/client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "net/http" 8 | "regexp" 9 | "strings" 10 | "time" 11 | 12 | "github.com/flant/glaball/pkg/config" 13 | "github.com/flant/glaball/pkg/util" 14 | "github.com/gofri/go-github-ratelimit/github_ratelimit" 15 | "github.com/google/go-github/v66/github" 16 | "github.com/gregjones/httpcache" 17 | 18 | "github.com/ahmetb/go-linq" 19 | "github.com/hashicorp/go-cleanhttp" 20 | "github.com/hashicorp/go-hclog" 21 | "github.com/hashicorp/go-retryablehttp" 22 | "github.com/xanzy/go-gitlab" 23 | ) 24 | 25 | const ( 26 | Gitlab string = "gitlab" 27 | Github string = "github" 28 | ) 29 | 30 | type Client struct { 31 | Hosts Hosts 32 | 33 | config *config.Config 34 | } 35 | 36 | type Hosts []*Host 37 | 38 | func (a Hosts) Projects(all bool) []string { 39 | k := len(a) 40 | if !all && k > 5 { 41 | k = 5 42 | } 43 | s := make([]string, 0, k) 44 | for _, h := range a[:k] { 45 | s = util.InsertString(s, h.ProjectName()) 46 | } 47 | 48 | if !all && k == 5 { 49 | s = append(s, "...") 50 | } 51 | 52 | return s 53 | } 54 | 55 | func (h Hosts) Len() int { return len(h) } 56 | func (h Hosts) Swap(i, j int) { h[i], h[j] = h[j], h[i] } 57 | func (h Hosts) Less(i, j int) bool { 58 | switch { 59 | case h[i].Team < h[j].Team: 60 | return true 61 | case h[i].Team > h[j].Team: 62 | return false 63 | } 64 | return h[i].Project < h[j].Project 65 | } 66 | 67 | type Host struct { 68 | Team, Project, Name, URL string 69 | Client *gitlab.Client 70 | GithubClient *github.Client 71 | Org string // TODO: 72 | } 73 | 74 | func (h Host) FullName() string { 75 | return fmt.Sprintf("%s.%s.%s", h.Team, h.Project, h.Name) 76 | } 77 | 78 | func (h Host) ProjectName() string { 79 | return fmt.Sprintf("%s.%s", h.Project, h.Name) 80 | } 81 | 82 | func (h *Host) CompareTo(c linq.Comparable) int { 83 | a, b := h.Project, c.(*Host).Project 84 | 85 | if a < b { 86 | return -1 87 | } else if a > b { 88 | return 1 89 | } 90 | 91 | return 0 92 | } 93 | 94 | func NewHttpClient(addresses map[string]string, cache *config.CacheOptions) (*http.Client, error) { 95 | dialer := &net.Dialer{ 96 | Timeout: 30 * time.Second, 97 | KeepAlive: 30 * time.Second, 98 | DualStack: true, 99 | } 100 | 101 | transport := cleanhttp.DefaultPooledTransport() 102 | transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { 103 | host, port, err := net.SplitHostPort(addr) 104 | if err != nil { 105 | return nil, err 106 | } 107 | if v, ok := addresses[host]; ok { 108 | dest := net.JoinHostPort(v, port) 109 | hclog.Default().Named("http-client").Debug("domain address has been modified", "original", addr, "modified", dest) 110 | addr = dest 111 | } 112 | return dialer.DialContext(ctx, network, addr) 113 | } 114 | 115 | if cache == nil || !cache.Enabled { 116 | return &http.Client{ 117 | Transport: transport, 118 | }, nil 119 | } 120 | 121 | diskCache, err := cache.DiskCache() 122 | if err != nil { 123 | return nil, err 124 | } 125 | 126 | return &http.Client{ 127 | Transport: &httpcache.Transport{ 128 | Transport: transport, 129 | Cache: diskCache, 130 | MarkCachedResponses: true, 131 | }, 132 | }, nil 133 | 134 | } 135 | 136 | func NewClient(cfg *config.Config) (*Client, error) { 137 | filter, err := regexp.Compile(cfg.Filter) 138 | if err != nil { 139 | return nil, err 140 | } 141 | 142 | customAddresses := make(map[string]string) 143 | 144 | httpClient, err := NewHttpClient(customAddresses, &cfg.Cache) 145 | if err != nil { 146 | return nil, err 147 | } 148 | 149 | // TODO: 150 | ghttpClient, err := github_ratelimit.NewRateLimitWaiterClient(httpClient.Transport) 151 | if err != nil { 152 | return nil, fmt.Errorf("failed to create github http client") 153 | } 154 | 155 | options := []gitlab.ClientOptionFunc{ 156 | gitlab.WithHTTPClient(httpClient), 157 | } 158 | 159 | if hclog.L().IsDebug() { 160 | options = append(options, gitlab.WithCustomLeveledLogger(hclog.Default().Named("go-gitlab"))) 161 | } 162 | 163 | client := Client{config: cfg} 164 | for team, projects := range cfg.Hosts { 165 | for project, hosts := range projects { 166 | for name, host := range hosts { 167 | fullName := strings.Join([]string{team, project, name}, ".") 168 | if !filter.MatchString(fullName) { 169 | continue 170 | } 171 | if host.Token == "" { 172 | return nil, fmt.Errorf("missing token for host %q", fullName) 173 | } 174 | 175 | // TODO: 176 | switch host.Type { 177 | case Github: 178 | // TODO: add cache 179 | cfg.Cache.Enabled = false 180 | 181 | client.Hosts = append(client.Hosts, &Host{ 182 | Team: team, 183 | Project: project, 184 | Name: name, 185 | URL: fmt.Sprintf("https://github.com/%s", host.Org), // TODO: 186 | Org: host.Org, 187 | GithubClient: github.NewClient(ghttpClient).WithAuthToken(host.Token), 188 | }) 189 | default: 190 | if host.URL == "" { 191 | return nil, fmt.Errorf("missing url for host %q", fullName) 192 | } 193 | if cfg.Cache.Enabled && !host.RateLimiter.Enabled { 194 | options = append(options, gitlab.WithCustomLimiter(&FakeLimiter{})) 195 | } 196 | gl, err := gitlab.NewClient(host.Token, 197 | append(options, gitlab.WithBaseURL(host.URL))...) 198 | if err != nil { 199 | return nil, err 200 | } 201 | if host.IP != "" { 202 | customAddresses[gl.BaseURL().Hostname()] = host.IP 203 | } 204 | client.Hosts = append(client.Hosts, &Host{ 205 | Team: team, 206 | Project: project, 207 | Name: name, 208 | URL: host.URL, 209 | Client: gl, 210 | }) 211 | } 212 | } 213 | } 214 | } 215 | 216 | return &client, nil 217 | 218 | } 219 | 220 | func (c *Client) WithCache() gitlab.RequestOptionFunc { 221 | return func(r *retryablehttp.Request) error { 222 | if c.config.Cache.Enabled { 223 | if c.config.Cache.TTL != nil { 224 | r.Header.Set("Cache-Control", fmt.Sprintf("max-age=%d", int(c.config.Cache.TTL.Seconds()))) 225 | r.Header.Set("etag", "W/\"00000000000000000000000000000000-1\"") 226 | } else { 227 | r.Header.Set("Cache-Control", "max-stale") 228 | } 229 | } 230 | return nil 231 | } 232 | } 233 | 234 | func (c *Client) WithNoCache() gitlab.RequestOptionFunc { 235 | return func(r *retryablehttp.Request) error { 236 | r.Header.Set("Cache-Control", "max-age=0") 237 | r.Header.Set("etag", "W/\"00000000000000000000000000000000-1\"") 238 | return nil 239 | } 240 | } 241 | 242 | // Used to avoid unnecessary noncached requests 243 | type FakeLimiter struct{} 244 | 245 | func (*FakeLimiter) Wait(context.Context) error { 246 | return nil 247 | } 248 | -------------------------------------------------------------------------------- /pkg/config/cache.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "time" 7 | 8 | "github.com/alecthomas/units" 9 | "github.com/gregjones/httpcache/diskcache" 10 | "github.com/peterbourgon/diskv" 11 | ) 12 | 13 | const ( 14 | DefaultCacheSize = "100MB" 15 | ) 16 | 17 | type CacheOptions struct { 18 | Enabled bool `yaml:"enabled" mapstructure:"enabled"` 19 | BasePath string `yaml:"path" mapstructure:"path"` 20 | CacheSizeMax string `yaml:"size" mapstructure:"size"` 21 | Compression bool `yaml:"compression" mapstructure:"compression"` 22 | TTL *time.Duration `yaml:"ttl" mapstructure:"ttl"` 23 | } 24 | 25 | func DefaultCacheDir() (string, error) { 26 | homeDir, err := os.UserHomeDir() 27 | if err != nil { 28 | return "", err 29 | } 30 | 31 | return filepath.Join(homeDir, ".cache", ApplicationName), nil 32 | } 33 | 34 | func (c *CacheOptions) DiskvOptions() (diskv.Options, error) { 35 | if c.BasePath == "" { 36 | defaultCacheDir, err := DefaultCacheDir() 37 | if err != nil { 38 | return diskv.Options{}, err 39 | } 40 | c.BasePath = defaultCacheDir 41 | } 42 | 43 | if c.CacheSizeMax == "" { 44 | c.CacheSizeMax = DefaultCacheSize 45 | } 46 | size, err := units.ParseStrictBytes(c.CacheSizeMax) 47 | if err != nil { 48 | return diskv.Options{}, err 49 | } 50 | 51 | var compression diskv.Compression = nil 52 | if c.Compression { 53 | compression = diskv.NewGzipCompression() 54 | } 55 | 56 | return diskv.Options{ 57 | BasePath: c.BasePath, 58 | CacheSizeMax: uint64(size), 59 | Compression: compression, 60 | }, nil 61 | } 62 | 63 | func (c *CacheOptions) Diskv() (*diskv.Diskv, error) { 64 | diskvOpts, err := c.DiskvOptions() 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | return diskv.New(diskvOpts), nil 70 | } 71 | 72 | func (c *CacheOptions) DiskCache() (*diskcache.Cache, error) { 73 | diskv, err := c.Diskv() 74 | if err != nil { 75 | return nil, err 76 | } 77 | return diskcache.NewWithDiskv(diskv), nil 78 | } 79 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | const ( 12 | ApplicationName = "glaball" 13 | ) 14 | 15 | type Config struct { 16 | Hosts Hosts `yaml:"hosts" mapstructure:"hosts"` 17 | Cache CacheOptions `yaml:"cache" mapstructure:"cache"` 18 | Filter string `yaml:"filter" mapstructure:"filter"` 19 | Threads int `yaml:"threads" mapstructure:"threads"` 20 | ShowAll bool `yaml:"all" mapstructure:"all"` 21 | } 22 | 23 | type Hosts map[string]map[string]map[string]Host 24 | 25 | type Host struct { 26 | URL string `yaml:"url" mapstructure:"url"` 27 | IP string `yaml:"ip" mapstructure:"ip"` 28 | Token string `yaml:"token" mapstructure:"token"` 29 | Type string `yaml:"type" mapstructure:"type"` 30 | Org string `yaml:"org" mapstructure:"org"` 31 | RateLimiter RateLimiterOptions `yaml:"rate_limiter" mapstructure:"rate_limiter"` 32 | } 33 | 34 | // TODO: 35 | type GitlabHost struct { 36 | URL string `yaml:"url" mapstructure:"url"` 37 | Token string `yaml:"token" mapstructure:"token"` 38 | } 39 | 40 | // TODO: 41 | type GithubHost struct { 42 | Org string `yaml:"org" mapstructure:"org"` 43 | Token string `yaml:"token" mapstructure:"token"` 44 | } 45 | 46 | type RateLimiterOptions struct { 47 | Enabled bool `yaml:"enabled" mapstructure:"enabled"` 48 | } 49 | 50 | func DefaultConfigDir() (string, error) { 51 | homeDir, err := os.UserHomeDir() 52 | if err != nil { 53 | return "", err 54 | } 55 | 56 | return filepath.Join(homeDir, ".config", ApplicationName), nil 57 | } 58 | 59 | func FromFile(path string) (*Config, error) { 60 | var config Config 61 | f, err := os.Open(path) 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | dec := yaml.NewDecoder(f) 67 | dec.KnownFields(true) 68 | if err := dec.Decode(&config); err != nil { 69 | return nil, fmt.Errorf("failed to parse config: %q error: %v", path, err) 70 | } 71 | 72 | return &config, nil 73 | } 74 | -------------------------------------------------------------------------------- /pkg/limiter/limiter.go: -------------------------------------------------------------------------------- 1 | package limiter 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/flant/glaball/pkg/client" 7 | ) 8 | 9 | const ( 10 | // DefaultLimit is the default concurrency limit 11 | DefaultLimit = 100 12 | ) 13 | 14 | type Error struct { 15 | Host *client.Host 16 | Err error 17 | } 18 | 19 | type Limiter struct { 20 | wg sync.WaitGroup 21 | mu sync.Mutex 22 | errs []Error 23 | 24 | sem chan struct{} 25 | } 26 | 27 | func NewLimiter(limit int) *Limiter { 28 | w := Limiter{sem: make(chan struct{}, limit)} 29 | return &w 30 | } 31 | 32 | func (l *Limiter) Error(host *client.Host, err error) { 33 | l.mu.Lock() 34 | l.errs = append(l.errs, Error{host, err}) 35 | l.mu.Unlock() 36 | } 37 | 38 | func (l *Limiter) Errors() []Error { 39 | return l.errs 40 | } 41 | 42 | func (l *Limiter) Add(delta int) { 43 | l.wg.Add(delta) 44 | } 45 | 46 | func (l *Limiter) Done() { 47 | l.wg.Done() 48 | } 49 | 50 | func (l *Limiter) Lock() { 51 | l.sem <- struct{}{} 52 | } 53 | 54 | func (l *Limiter) Unlock() { 55 | <-l.sem 56 | } 57 | 58 | func (l *Limiter) Wait() { 59 | l.wg.Wait() 60 | } 61 | -------------------------------------------------------------------------------- /pkg/sort/sort.go: -------------------------------------------------------------------------------- 1 | package sort 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/flant/glaball/pkg/client" 7 | 8 | "github.com/ahmetb/go-linq" 9 | ) 10 | 11 | var ( 12 | // We use negative values because these fields don't exist in any struct 13 | byHost = FieldIndex{-1, 3} 14 | byLen = FieldIndex{-1, 2} 15 | empty = FieldIndex{-1, 1} 16 | notFound = FieldIndex{-1, 0} 17 | ) 18 | 19 | type Options struct { 20 | SortBy, GroupBy string 21 | 22 | OrderBy []string 23 | StructType interface{} 24 | } 25 | 26 | type Result struct { 27 | Count int 28 | Key string 29 | Elements Elements 30 | Cached Cached 31 | } 32 | 33 | type Element struct { 34 | Host *client.Host 35 | Struct interface{} 36 | Cached Cached 37 | } 38 | 39 | type Cached bool 40 | 41 | func (c Cached) String() string { 42 | if c { 43 | return "yes" 44 | } 45 | return "no" 46 | } 47 | 48 | type Elements []interface{} 49 | 50 | func (e Elements) Hosts() client.Hosts { 51 | s := make(client.Hosts, 0, len(e)) 52 | for _, v := range e { 53 | s = append(s, v.(Element).Host) 54 | } 55 | return s 56 | } 57 | 58 | func (e Elements) Cached() (cached Cached) { 59 | for _, v := range e { 60 | if !v.(Element).Cached { 61 | return false 62 | } 63 | } 64 | return true 65 | } 66 | 67 | func (e Elements) Typed() []Element { 68 | s := make([]Element, 0, len(e)) 69 | for _, v := range e { 70 | s = append(s, v.(Element)) 71 | } 72 | return s 73 | } 74 | 75 | func FromChannel(ch chan interface{}, opt *Options) (results []Result) { 76 | FromChannelQuery(ch, opt).ToSlice(&results) 77 | return results 78 | } 79 | 80 | func FromChannelQuery(ch chan interface{}, opt *Options) linq.Query { 81 | var orderedQuery linq.OrderedQuery 82 | // Initialize GroupBy key with `empty` value 83 | groupBy := empty 84 | // Initialize _first_ OrderBy key with `empty` value 85 | first := empty 86 | 87 | // Create `json` tag names tree of current struct fields 88 | // to get their values fast (by index) 89 | t := JsonFieldIndexTree(opt.StructType) 90 | // Add some additional fields 91 | t.Insert("host", byHost) 92 | t.Insert("count", byLen) 93 | 94 | query := linq.FromChannel(ch) 95 | if opt.GroupBy != "" { 96 | groupBy = notFound 97 | // Set GroupBy key to some field name if struct has it 98 | if v, ok := t.Get(opt.GroupBy); ok { 99 | groupBy = v.(FieldIndex) 100 | } 101 | query = query.GroupBy(GroupBy(groupBy)) 102 | } 103 | 104 | if opt.OrderBy != nil { 105 | first = notFound 106 | // Set _first_ OrderBy key to some field name if struct has it 107 | if v, ok := t.Get(opt.OrderBy[0]); ok { 108 | first = v.(FieldIndex) 109 | } 110 | } 111 | 112 | switch o := opt; { 113 | case o.GroupBy != "" && o.OrderBy[0] == "count": 114 | orderedQuery = query.OrderBy(ByLen()) // Order by count first if it is declared 115 | orderedQuery = orderedQuery.ThenByDescending(ByKey()) // and then by GroupBy key 116 | default: 117 | orderedQuery = query.OrderByDescending(OrderBy(groupBy, first)) // Otherwise use GroupBy key and _first_ OrderBy key 118 | } 119 | 120 | // Add additional order if we have other OrderBy keys 121 | for _, key := range opt.OrderBy[1:] { 122 | idx := notFound 123 | if v, ok := t.Get(key); ok { 124 | idx = v.(FieldIndex) 125 | } 126 | orderedQuery = orderedQuery.ThenByDescending(OrderBy(groupBy, idx)) 127 | } 128 | 129 | // Set ascending or descending order. Default is descending. 130 | switch opt.SortBy { 131 | case "asc": 132 | query = orderedQuery.Reverse() 133 | default: 134 | query = orderedQuery.Query 135 | } 136 | 137 | query = query.Select(func(i interface{}) interface{} { 138 | // Check if we have a group or single element 139 | switch v := i.(type) { 140 | case Element: 141 | var key interface{} 142 | // Use specific key if we want to order results by host project name 143 | switch opt.OrderBy[0] { 144 | case "host": 145 | key = v.Host.Project 146 | default: 147 | key = ValidFieldValue(t, opt.OrderBy, v.Struct) 148 | } 149 | return Result{ 150 | Count: 1, // Count of single element is always 1 151 | Key: fmt.Sprint(key), 152 | Elements: Elements{v}, 153 | Cached: v.Cached, 154 | } 155 | case linq.Group: 156 | return Result{ 157 | Count: len(v.Group), 158 | Key: fmt.Sprint(v.Key), 159 | Elements: Elements(v.Group), 160 | Cached: Elements(v.Group).Cached(), 161 | } 162 | default: 163 | return v 164 | } 165 | }) 166 | 167 | return query 168 | } 169 | 170 | func GroupBy(groupBy FieldIndex) (func(i interface{}) interface{}, func(i interface{}) interface{}) { 171 | return func(i interface{}) interface{} { 172 | return FieldIndexValue(groupBy, i.(Element).Struct) 173 | }, 174 | func(i interface{}) interface{} { return i } 175 | } 176 | 177 | func OrderBy(groupBy, orderBy FieldIndex) func(i interface{}) interface{} { 178 | switch v := orderBy; { 179 | case v.Equal(byHost): 180 | // Return Host.Project name 181 | return ByHost() 182 | case v.Equal(byLen): 183 | // Return length of the group if we have GroupBy key 184 | if !groupBy.Equal(empty) { 185 | return ByLen() 186 | } 187 | // Return count == 1 if we don't have any groups 188 | return func(i interface{}) interface{} { return 1 } 189 | case orderBy.Equal(notFound): 190 | // Return length of the group if we have GroupBy key 191 | if !groupBy.Equal(empty) { 192 | return ByLen() 193 | } 194 | // Return count == 1 if we don't have any groups 195 | return func(i interface{}) interface{} { return 1 } 196 | } 197 | 198 | // Order by GroupBy key if it exists 199 | if !groupBy.Equal(empty) { 200 | return ByKey() 201 | } 202 | 203 | // Otherwise order by other keys 204 | return ByFieldIndex(orderBy) 205 | } 206 | 207 | // Order by count 208 | func ByLen() func(i interface{}) interface{} { 209 | return func(i interface{}) interface{} { return len(i.(linq.Group).Group) } 210 | } 211 | 212 | // Order by GroupBy key 213 | func ByKey() func(i interface{}) interface{} { 214 | return func(i interface{}) interface{} { return i.(linq.Group).Key } 215 | } 216 | 217 | // Order by Host.Project key 218 | func ByHost() func(i interface{}) interface{} { 219 | return func(i interface{}) interface{} { 220 | if v, ok := i.(Element); ok { 221 | return v.Host 222 | } 223 | 224 | return Elements(i.(linq.Group).Group).Hosts().Projects(true)[0] 225 | } 226 | } 227 | 228 | // Order by the field 229 | func ByFieldIndex(n FieldIndex) func(i interface{}) interface{} { 230 | return func(i interface{}) interface{} { 231 | if v, ok := i.(Element); ok { 232 | return FieldIndexValue(n, v.Struct) 233 | } 234 | 235 | return FieldIndexValue(n, i) 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /pkg/sort/util.go: -------------------------------------------------------------------------------- 1 | // TODO: refactoring 2 | 3 | package sort 4 | 5 | import ( 6 | "fmt" 7 | "reflect" 8 | "strings" 9 | 10 | "github.com/armon/go-radix" 11 | ) 12 | 13 | // Represents an index of struct field for (reflect.Value).FieldByIndex func 14 | type FieldIndex []int 15 | 16 | // Check if struct really has a field (it must have positive indices) 17 | func (f FieldIndex) Negative() bool { 18 | for i := 0; i < len(f); i++ { 19 | return f[i] < 0 20 | } 21 | return true 22 | } 23 | 24 | // Check equality of FieldIndex slices 25 | func (f FieldIndex) Equal(other FieldIndex) bool { 26 | if len(f) != len(other) { 27 | return false 28 | } 29 | for i, v := range f { 30 | if v != other[i] { 31 | return false 32 | } 33 | } 34 | return true 35 | } 36 | 37 | // Get actual value of struct field by its index 38 | func FieldIndexValue(i FieldIndex, v interface{}) interface{} { 39 | rv := reflect.ValueOf(v) 40 | // Get actual value if field is a pointer 41 | if rv.Kind() == reflect.Ptr { 42 | rv = rv.Elem() 43 | } 44 | if !rv.IsValid() || rv.Kind() != reflect.Struct { 45 | panic(fmt.Errorf("invalid struct: %#v", v)) 46 | } 47 | rf := rv.FieldByIndex(i) 48 | // Get actual value if field is a pointer 49 | if rf.Kind() == reflect.Ptr { 50 | rf = rf.Elem() 51 | } 52 | if !rf.IsValid() { 53 | panic(fmt.Errorf("invalid struct field: %q for %q", rf.Type().Name(), rv.Type())) 54 | } 55 | return rf.Interface() 56 | } 57 | 58 | // Get actual value of any valid struct field in _keys_ slice 59 | // Panic if all fields are invalid or not found 60 | func ValidFieldValue(t *radix.Tree, keys []string, v interface{}) interface{} { 61 | rv := reflect.ValueOf(v) 62 | // Get actual value if field is a pointer 63 | if rv.Kind() == reflect.Ptr { 64 | rv = rv.Elem() 65 | } 66 | for _, k := range keys { 67 | if i, ok := t.Get(k); ok { 68 | idx := i.(FieldIndex) 69 | // Check if field really exists 70 | if idx.Negative() { 71 | continue 72 | } 73 | rf := rv.FieldByIndex(idx) 74 | // Get actual value if field is a pointer 75 | if rf.Kind() == reflect.Ptr { 76 | rf = rf.Elem() 77 | } 78 | // Ignore invalid fields 79 | if !rf.IsValid() { 80 | continue 81 | } 82 | return rf.Interface() 83 | } 84 | } 85 | 86 | panic(fmt.Sprintf("field not found: %q: %q", keys, rv.Type())) 87 | } 88 | 89 | // Create a radix tree of struct fields' json tags and their indices 90 | func JsonFieldIndexTree(v interface{}) *radix.Tree { 91 | t := radix.New() 92 | jsonFieldIndexTree(reflect.TypeOf(v), t, nil, nil) 93 | return t 94 | } 95 | 96 | // Create a radix tree of struct fields' json tags and their indices 97 | func jsonFieldIndexTree(rt reflect.Type, t *radix.Tree, prefix []string, index FieldIndex) { 98 | switch k := rt.Kind(); { 99 | case k == reflect.Ptr: // Get actual value if field is a pointer 100 | rt = rt.Elem() 101 | case k != reflect.Struct: 102 | panic(fmt.Errorf("invalid struct: %#q", rt)) 103 | } 104 | 105 | for i := 0; i < rt.NumField(); i++ { 106 | f := rt.Field(i) 107 | ftyp := f.Type 108 | // Get actual value if field is a pointer 109 | if ftyp.Kind() == reflect.Ptr { 110 | ftyp = f.Type.Elem() 111 | } 112 | // Get json tag of each valid struct field and insert it into tree 113 | if tag, ok := f.Tag.Lookup("json"); ok { 114 | tagName := strings.Split(tag, ",")[0] 115 | // Set tag name like `parent.child` 116 | pfx := append(prefix, tagName) 117 | idx := append(index, i) 118 | t.Insert(strings.Join(pfx, "."), idx) 119 | // Go through if field is a struct 120 | if ftyp.Kind() == reflect.Struct { 121 | jsonFieldIndexTree(f.Type, t, pfx, idx) 122 | } 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /pkg/sort/v2/sort.go: -------------------------------------------------------------------------------- 1 | package sort 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strings" 7 | 8 | "github.com/ahmetb/go-linq" 9 | "github.com/flant/glaball/pkg/client" 10 | "github.com/jmoiron/sqlx/reflectx" 11 | ) 12 | 13 | var ( 14 | mapper = reflectx.NewMapper("json") 15 | 16 | byHostFI = reflectx.FieldInfo{ 17 | Path: "host", 18 | Name: "host", 19 | } 20 | 21 | byLenFI = reflectx.FieldInfo{ 22 | Path: "count", 23 | Name: "count", 24 | } 25 | ) 26 | 27 | type Options struct { 28 | SortBy, GroupBy string 29 | 30 | OrderBy []string 31 | StructType interface{} 32 | } 33 | 34 | type Result struct { 35 | Count int 36 | Key string 37 | Elements Elements 38 | Cached Cached 39 | } 40 | 41 | type Element struct { 42 | Host *client.Host 43 | Struct interface{} 44 | Cached Cached 45 | } 46 | 47 | type Cached bool 48 | 49 | func (c Cached) String() string { 50 | if c { 51 | return "yes" 52 | } 53 | return "no" 54 | } 55 | 56 | type Elements []interface{} 57 | 58 | func (e Elements) Hosts() client.Hosts { 59 | s := make(client.Hosts, len(e)) 60 | for i, v := range e { 61 | s[i] = v.(Element).Host 62 | } 63 | return s 64 | } 65 | 66 | func (e Elements) Cached() (cached Cached) { 67 | for _, v := range e { 68 | if !v.(Element).Cached { 69 | return false 70 | } 71 | } 72 | return true 73 | } 74 | 75 | func (e Elements) Typed() []Element { 76 | s := make([]Element, len(e)) 77 | for i, v := range e { 78 | s[i] = v.(Element) 79 | } 80 | return s 81 | } 82 | 83 | func FromChannel(ch chan interface{}, opt *Options) ([]Result, error) { 84 | results := make([]Result, 0) 85 | query, err := FromChannelQuery(ch, opt) 86 | if err != nil { 87 | return nil, err 88 | } 89 | 90 | query.ToSlice(&results) 91 | 92 | return results, nil 93 | } 94 | 95 | func FromChannelQuery(ch chan interface{}, opt *Options) (linq.Query, error) { 96 | var ( 97 | query linq.Query 98 | orderedQuery linq.OrderedQuery 99 | groupBy *reflectx.FieldInfo 100 | first *reflectx.FieldInfo 101 | ) 102 | 103 | query = linq.FromChannel(ch) 104 | m := mapper.TypeMap(reflect.TypeOf(opt.StructType)) 105 | m.Paths[byHostFI.Name] = &byHostFI 106 | m.Names[byHostFI.Name] = &byHostFI 107 | m.Paths[byLenFI.Path] = &byLenFI 108 | m.Names[byLenFI.Name] = &byLenFI 109 | 110 | if f := opt.GroupBy; f != "" { 111 | groupBy = m.GetByPath(f) 112 | if groupBy == nil { 113 | return linq.Query{}, fmt.Errorf("invalid struct field: %s", f) 114 | } 115 | query = query.GroupBy(GroupBy(groupBy)) 116 | 117 | } 118 | 119 | if f := opt.OrderBy; f != nil { 120 | first = m.GetByPath(f[0]) 121 | if first == nil { 122 | return linq.Query{}, fmt.Errorf("invalid struct field: %s", f[0]) 123 | } 124 | } 125 | 126 | orderedQuery = query.OrderByDescending(OrderBy(groupBy, first)) 127 | 128 | if groupBy != nil && first != nil && first.Name == byLenFI.Name { 129 | orderedQuery = query.OrderBy(ByLen()) 130 | orderedQuery = orderedQuery.ThenByDescending(ByKey()) 131 | } 132 | 133 | for _, key := range opt.OrderBy[1:] { 134 | v := m.GetByPath(key) 135 | if v == nil { 136 | return linq.Query{}, fmt.Errorf("invalid struct field: %s", key) 137 | } 138 | orderedQuery = orderedQuery.ThenByDescending(OrderBy(groupBy, v)) 139 | } 140 | 141 | query = orderedQuery.Query 142 | if opt.SortBy == "asc" { 143 | query = orderedQuery.Reverse() 144 | } 145 | 146 | query = query.Select(func(i interface{}) interface{} { 147 | // Check if we have a group or single element 148 | switch v := i.(type) { 149 | case Element: 150 | key, err := ValidFieldValue(opt.OrderBy, v.Struct) 151 | if err != nil { 152 | return nil 153 | } 154 | return Result{ 155 | Count: 1, // Count of single element is always 1 156 | Key: fmt.Sprint(key), 157 | Elements: Elements{v}, 158 | Cached: v.Cached, 159 | } 160 | case linq.Group: 161 | return Result{ 162 | Count: len(v.Group), 163 | Key: fmt.Sprint(v.Key), 164 | Elements: Elements(v.Group), 165 | Cached: Elements(v.Group).Cached(), 166 | } 167 | default: 168 | return v 169 | } 170 | }) 171 | 172 | return query, nil 173 | } 174 | 175 | func GroupBy(groupBy *reflectx.FieldInfo) (func(i interface{}) interface{}, func(i interface{}) interface{}) { 176 | return func(i interface{}) interface{} { 177 | return reflectx.FieldByIndexesReadOnly(reflect.ValueOf(i.(Element).Struct), groupBy.Index).Interface() 178 | }, 179 | func(i interface{}) interface{} { return i } 180 | } 181 | 182 | func OrderBy(groupBy, orderBy *reflectx.FieldInfo) func(i interface{}) interface{} { 183 | if orderBy.Name == byHostFI.Name { 184 | // Return Host.Project name 185 | return ByHost() 186 | } 187 | 188 | if orderBy.Name == byLenFI.Name { 189 | // Return length of the group if we have GroupBy key 190 | if groupBy != nil { 191 | return ByLen() 192 | } 193 | // Return count == 1 if we don't have any groups 194 | return func(i interface{}) interface{} { return 1 } 195 | } 196 | 197 | // Order by GroupBy key if it exists 198 | if groupBy != nil { 199 | return ByKey() 200 | } 201 | 202 | // Otherwise order by other keys 203 | return ByFieldIndex(orderBy) 204 | } 205 | 206 | // Order by count 207 | func ByLen() func(i interface{}) interface{} { 208 | return func(i interface{}) interface{} { return len(i.(linq.Group).Group) } 209 | } 210 | 211 | // Order by GroupBy key 212 | func ByKey() func(i interface{}) interface{} { 213 | return func(i interface{}) interface{} { return strings.ToLower(fmt.Sprint(i.(linq.Group).Key)) } 214 | } 215 | 216 | // Order by Host.Project key 217 | func ByHost() func(i interface{}) interface{} { 218 | return func(i interface{}) interface{} { 219 | if v, ok := i.(Element); ok { 220 | return v.Host 221 | } 222 | 223 | return Elements(i.(linq.Group).Group).Hosts().Projects(true)[0] 224 | } 225 | } 226 | 227 | // Order by the field 228 | func ByFieldIndex(fi *reflectx.FieldInfo) func(i interface{}) interface{} { 229 | return func(i interface{}) interface{} { 230 | if v, ok := i.(Element); ok { 231 | return strings.ToLower(fmt.Sprint(reflectx.FieldByIndexesReadOnly(reflect.ValueOf(v.Struct), fi.Index).Interface())) 232 | } 233 | 234 | return strings.ToLower(fmt.Sprint(reflectx.FieldByIndexesReadOnly(reflect.ValueOf(i), fi.Index).Interface())) 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /pkg/sort/v2/util.go: -------------------------------------------------------------------------------- 1 | package sort 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | 7 | "github.com/jmoiron/sqlx/reflectx" 8 | ) 9 | 10 | func ValidFieldValue(keys []string, v interface{}) (interface{}, error) { 11 | m := mapper.TypeMap(reflect.TypeOf(v)) 12 | rv := reflect.ValueOf(v) 13 | for _, k := range keys { 14 | if fi := m.GetByPath(k); fi != nil { 15 | fv := reflectx.FieldByIndexesReadOnly(rv, fi.Index) 16 | if fi.Field.Type.Kind() == reflect.Ptr { 17 | fv = fv.Elem() 18 | } 19 | if rfv := fv.Interface(); rfv != nil && rfv != v { 20 | return rfv, nil 21 | } 22 | } 23 | } 24 | return nil, fmt.Errorf("field not found: %q: %q", keys, rv.Type()) 25 | } 26 | 27 | func ValidOrderBy(keys []string, v interface{}) bool { 28 | m := mapper.TypeMap(reflect.TypeOf(v)) 29 | for _, k := range keys { 30 | if fi := m.GetByPath(k); fi != nil { 31 | return true 32 | } 33 | } 34 | return false 35 | } 36 | -------------------------------------------------------------------------------- /pkg/util/dict.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | ) 8 | 9 | type Item struct { 10 | Key string 11 | Value string 12 | } 13 | 14 | type Dict []Item 15 | 16 | func (d Dict) Keys() []string { 17 | s := make([]string, len(d)) 18 | for i, v := range d { 19 | s[i] = v.Key 20 | } 21 | return s 22 | } 23 | 24 | func (d Dict) Values() []string { 25 | s := make([]string, len(d)) 26 | for i, v := range d { 27 | s[i] = v.Value 28 | } 29 | return s 30 | } 31 | 32 | func (d Dict) Print(w io.Writer, sep string, args ...interface{}) error { 33 | if len(args) != len(d) { 34 | return fmt.Errorf("wrong number of arguments") 35 | } 36 | _, err := fmt.Fprintf(w, strings.Join(d.Values(), sep)+"\n", args...) 37 | return err 38 | } 39 | -------------------------------------------------------------------------------- /pkg/util/pointers.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | "time" 8 | 9 | "github.com/xanzy/go-gitlab" 10 | ) 11 | 12 | type boolPtrValue struct{ v **bool } 13 | 14 | func NewBoolPtrValue(p **bool) *boolPtrValue { 15 | return &boolPtrValue{p} 16 | } 17 | 18 | func (f *boolPtrValue) Set(s string) error { 19 | v, err := strconv.ParseBool(s) 20 | if err == nil { 21 | *f.v = gitlab.Bool(v) 22 | } 23 | return err 24 | } 25 | 26 | func (f *boolPtrValue) Get() interface{} { 27 | if *f.v != nil { 28 | return **f.v 29 | } 30 | return nil 31 | } 32 | 33 | func (f *boolPtrValue) String() string { 34 | return fmt.Sprintf("%v", *f.v) 35 | } 36 | 37 | func (f *boolPtrValue) Type() string { 38 | return "bool" 39 | } 40 | 41 | type stringPtrValue struct{ v **string } 42 | 43 | func NewStringPtrValue(p **string) *stringPtrValue { 44 | return &stringPtrValue{p} 45 | } 46 | 47 | func (f *stringPtrValue) Set(s string) error { 48 | if s != "" { 49 | *f.v = gitlab.String(s) 50 | } 51 | 52 | return nil 53 | } 54 | 55 | func (f *stringPtrValue) String() string { 56 | if *f.v == nil { 57 | return "" 58 | } 59 | return string(**f.v) 60 | } 61 | 62 | func (f *stringPtrValue) Type() string { 63 | return "string" 64 | } 65 | 66 | type enumPtrValue struct { 67 | v **string 68 | options []string 69 | } 70 | 71 | func NewEnumPtrValue(p **string, options ...string) *enumPtrValue { 72 | return &enumPtrValue{p, options} 73 | } 74 | 75 | func (f *enumPtrValue) Set(s string) error { 76 | for _, v := range f.options { 77 | if v == s { 78 | *f.v = gitlab.String(s) 79 | return nil 80 | } 81 | } 82 | 83 | return fmt.Errorf("enum value must be one of %s, got '%s'", strings.Join(f.options, ","), s) 84 | } 85 | 86 | func (f *enumPtrValue) String() string { 87 | if *f.v == nil { 88 | return "" 89 | } 90 | return string(**f.v) 91 | } 92 | 93 | func (f *enumPtrValue) Type() string { 94 | return "string" 95 | } 96 | 97 | type timePtrValue struct{ v **time.Time } 98 | 99 | func NewTimePtrValue(p **time.Time) *timePtrValue { 100 | return &timePtrValue{p} 101 | } 102 | 103 | func (f *timePtrValue) Set(s string) error { 104 | t, err := time.Parse(time.RFC3339, s) 105 | if err == nil { 106 | *f.v = gitlab.Time(t) 107 | } 108 | 109 | return err 110 | } 111 | 112 | func (f *timePtrValue) String() string { 113 | if *f.v == nil { 114 | return "" 115 | } 116 | t := **f.v 117 | return t.String() 118 | } 119 | 120 | func (f *timePtrValue) Type() string { 121 | return "date" 122 | } 123 | 124 | type intPtrValue struct{ v **int } 125 | 126 | func NewIntPtrValue(p **int) *intPtrValue { 127 | return &intPtrValue{p} 128 | } 129 | 130 | func (f *intPtrValue) Set(s string) error { 131 | v, err := strconv.Atoi(s) 132 | if err == nil { 133 | *f.v = gitlab.Int(v) 134 | } 135 | 136 | return err 137 | } 138 | 139 | func (f *intPtrValue) String() string { 140 | if *f.v == nil { 141 | return "" 142 | } 143 | return fmt.Sprint(**f.v) 144 | } 145 | 146 | func (f *intPtrValue) Type() string { 147 | return "int" 148 | } 149 | 150 | type visibilityPtrValue struct{ v **gitlab.VisibilityValue } 151 | 152 | func NewVisibilityPtrValue(p **gitlab.VisibilityValue) *visibilityPtrValue { 153 | return &visibilityPtrValue{p} 154 | } 155 | 156 | func (f *visibilityPtrValue) Set(s string) error { 157 | options := []string{ 158 | string(gitlab.PrivateVisibility), 159 | string(gitlab.InternalVisibility), 160 | string(gitlab.PublicVisibility), 161 | } 162 | 163 | for _, opt := range options { 164 | if s == opt { 165 | *f.v = gitlab.Visibility(gitlab.VisibilityValue(opt)) 166 | return nil 167 | } 168 | } 169 | 170 | return fmt.Errorf("visibility value must be one of %s, got '%s'", strings.Join(options, ","), s) 171 | 172 | } 173 | 174 | func (f *visibilityPtrValue) String() string { 175 | if *f.v == nil { 176 | return "" 177 | } 178 | return string(**f.v) 179 | } 180 | 181 | func (f *visibilityPtrValue) Type() string { 182 | return "string" 183 | } 184 | 185 | type labelsPtrValue struct{ v **gitlab.LabelOptions } 186 | 187 | func NewLabelsPtrValue(p **gitlab.LabelOptions) *labelsPtrValue { 188 | return &labelsPtrValue{p} 189 | } 190 | 191 | func (f *labelsPtrValue) Set(s string) error { 192 | if *f.v == nil { 193 | *f.v = new(gitlab.LabelOptions) 194 | } 195 | **f.v = append(**f.v, s) 196 | 197 | return nil 198 | } 199 | 200 | func (f *labelsPtrValue) IsCumulative() bool { 201 | return true 202 | } 203 | 204 | func (f *labelsPtrValue) String() string { 205 | if *f.v == nil { 206 | return "" 207 | } 208 | return strings.Join(**f.v, ",") 209 | } 210 | 211 | func (f *labelsPtrValue) Type() string { 212 | return "[]string" 213 | } 214 | 215 | type assigneeIDPtrValue struct{ v **gitlab.AssigneeIDValue } 216 | 217 | func NewAssigneeIDPtrValue(p **gitlab.AssigneeIDValue) *assigneeIDPtrValue { 218 | return &assigneeIDPtrValue{p} 219 | } 220 | 221 | func (f *assigneeIDPtrValue) Set(s string) error { 222 | *f.v = gitlab.AssigneeID(s) 223 | 224 | return nil 225 | 226 | } 227 | 228 | func (f *assigneeIDPtrValue) String() string { 229 | if *f.v == nil { 230 | return "" 231 | } 232 | return fmt.Sprintf("%v", **f.v) 233 | } 234 | 235 | func (f *assigneeIDPtrValue) Type() string { 236 | return "int|string" 237 | } 238 | 239 | // TODO: 240 | // type approverIDsPtrValue struct { 241 | // v **gitlab.ApproverIDsValue 242 | // ids []int 243 | // } 244 | 245 | // func NewApproverIDsPtrValue(p **gitlab.ApproverIDsValue) *approverIDsPtrValue { 246 | // return &approverIDsPtrValue{p, nil} 247 | // } 248 | 249 | // func (f *approverIDsPtrValue) Set(s string) error { 250 | // switch s { 251 | // case string(gitlab.UserIDAny): 252 | // *f.v = gitlab.ApproverIDs(s) 253 | // case string(gitlab.UserIDNone): 254 | // *f.v = gitlab.ApproverIDs(s) 255 | // default: 256 | // id, err := strconv.Atoi(s) 257 | // if err != nil { 258 | // return err 259 | // } 260 | // if *f.v == nil { 261 | // *f.v = gitlab.ApproverIDs([]int{id}) 262 | // } 263 | // } 264 | // if *f.v == nil { 265 | // *f.v = gitlab.ApproverIDs(s) 266 | // } 267 | // **f.v = append(**f.v, s) 268 | 269 | // return nil 270 | // } 271 | 272 | // func (f *approverIDsPtrValue) IsCumulative() bool { 273 | // return true 274 | // } 275 | 276 | // func (f *approverIDsPtrValue) String() string { 277 | // if *f.v == nil { 278 | // return "" 279 | // } 280 | // return strings.Join(**f.v, ",") 281 | // } 282 | 283 | // func (f *approverIDsPtrValue) Type() string { 284 | // return "[]string" 285 | // } 286 | 287 | type reviewerIDPtrValue struct{ v **gitlab.ReviewerIDValue } 288 | 289 | func NewReviewerIDPtrValue(p **gitlab.ReviewerIDValue) *reviewerIDPtrValue { 290 | return &reviewerIDPtrValue{p} 291 | } 292 | 293 | func (f *reviewerIDPtrValue) Set(s string) error { 294 | *f.v = gitlab.ReviewerID(s) 295 | 296 | return nil 297 | 298 | } 299 | 300 | func (f *reviewerIDPtrValue) String() string { 301 | if *f.v == nil { 302 | return "" 303 | } 304 | return fmt.Sprintf("%v", **f.v) 305 | } 306 | 307 | func (f *reviewerIDPtrValue) Type() string { 308 | return "int|string" 309 | } 310 | 311 | type accessLevelValue struct{ v **gitlab.AccessLevelValue } 312 | 313 | func NewAccessLevelValue(p **gitlab.AccessLevelValue) *accessLevelValue { 314 | return &accessLevelValue{p} 315 | } 316 | 317 | func (f *accessLevelValue) Set(s string) error { 318 | v, err := strconv.Atoi(s) 319 | if err == nil { 320 | l := gitlab.AccessLevelValue(v) 321 | *f.v = &l 322 | } 323 | 324 | return err 325 | } 326 | 327 | func (f *accessLevelValue) String() string { 328 | if *f.v == nil { 329 | return "" 330 | } 331 | return fmt.Sprint(**f.v) 332 | } 333 | 334 | func (f *accessLevelValue) Type() string { 335 | return "int" 336 | } 337 | -------------------------------------------------------------------------------- /pkg/util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "sort" 7 | "strings" 8 | ) 9 | 10 | func AskUser(msg string) bool { 11 | var q string 12 | 13 | fmt.Printf("%s [y/N] ", msg) 14 | fmt.Scanln(&q) 15 | 16 | if len(q) > 0 && 17 | strings.ToLower(q[:1]) == "y" { 18 | return true 19 | } 20 | 21 | fmt.Println("Aborted") 22 | os.Exit(0) 23 | return false 24 | } 25 | 26 | // The slice must be sorted in ascending order 27 | func ContainsString(slice []string, item string) bool { 28 | if !sort.StringsAreSorted(slice) { 29 | sort.Strings(slice) 30 | } 31 | idx := sort.SearchStrings(slice, item) 32 | if idx == len(slice) { 33 | return false 34 | } 35 | return slice[idx] == item 36 | } 37 | 38 | // The slice must be sorted in ascending order 39 | func ContainsInt(slice []int, item int) bool { 40 | if !sort.IntsAreSorted(slice) { 41 | sort.Ints(slice) 42 | } 43 | idx := sort.SearchInts(slice, item) 44 | if idx == len(slice) { 45 | return false 46 | } 47 | return slice[idx] == item 48 | } 49 | 50 | func InsertString(slice []string, item string) []string { 51 | if idx := sort.SearchStrings(slice, item); idx == len(slice) { 52 | slice = append(slice, item) 53 | } else { 54 | slice = append(slice[:idx+1], slice[idx:]...) 55 | slice[idx] = item 56 | } 57 | return slice 58 | } 59 | -------------------------------------------------------------------------------- /pkg/util/values.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type enumValue struct { 9 | v *string 10 | options []string 11 | } 12 | 13 | func NewEnumValue(p *string, options ...string) *enumValue { 14 | return &enumValue{p, options} 15 | } 16 | 17 | func (f *enumValue) Set(s string) error { 18 | for _, v := range f.options { 19 | if v == s { 20 | *f.v = s 21 | return nil 22 | } 23 | } 24 | 25 | return fmt.Errorf("enum value must be one of %s, got '%s'", strings.Join(f.options, ","), s) 26 | } 27 | 28 | func (f *enumValue) String() string { 29 | return *f.v 30 | } 31 | 32 | func (f *enumValue) Type() string { 33 | return "string" 34 | } 35 | -------------------------------------------------------------------------------- /pkg/util/version.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package util 15 | 16 | import ( 17 | "bytes" 18 | "fmt" 19 | "html/template" 20 | "runtime" 21 | "strings" 22 | ) 23 | 24 | // Build information. Populated at build-time. 25 | var ( 26 | Version string = "1.0.0" 27 | Revision string 28 | Branch string 29 | BuildUser string 30 | BuildDate string 31 | GoVersion = runtime.Version() 32 | ) 33 | 34 | // versionInfoTmpl contains the template used by Info. 35 | var versionInfoTmpl = ` 36 | {{.program}}, version {{.version}} (branch: {{.branch}}, revision: {{.revision}}) 37 | build user: {{.buildUser}} 38 | build date: {{.buildDate}} 39 | go version: {{.goVersion}} 40 | platform: {{.platform}} 41 | ` 42 | 43 | // Print returns version information. 44 | func PrintVersion(program string) string { 45 | m := map[string]string{ 46 | "program": program, 47 | "version": Version, 48 | "revision": Revision, 49 | "branch": Branch, 50 | "buildUser": BuildUser, 51 | "buildDate": BuildDate, 52 | "goVersion": GoVersion, 53 | "platform": runtime.GOOS + "/" + runtime.GOARCH, 54 | } 55 | t := template.Must(template.New("version").Parse(versionInfoTmpl)) 56 | 57 | var buf bytes.Buffer 58 | if err := t.ExecuteTemplate(&buf, "version", m); err != nil { 59 | panic(err) 60 | } 61 | return strings.TrimSpace(buf.String()) 62 | } 63 | 64 | // Info returns version, branch and revision information. 65 | func VersionInfo() string { 66 | return fmt.Sprintf("(version=%s, branch=%s, revision=%s)", Version, Branch, Revision) 67 | } 68 | 69 | // BuildContext returns goVersion, buildUser and buildDate information. 70 | func BuildContext() string { 71 | return fmt.Sprintf("(go=%s, user=%s, date=%s)", GoVersion, BuildUser, BuildDate) 72 | } 73 | 74 | func VersionString() string { 75 | return fmt.Sprintf("(version=%s, branch=%s, revision=%s, go=%s, user=%s, date=%s)", Version, Branch, Revision, GoVersion, BuildUser, BuildDate) 76 | } 77 | --------------------------------------------------------------------------------