├── .editorconfig ├── .gitignore ├── .gitmodules ├── .travis.yml ├── LICENSE ├── README.md ├── build └── build.go ├── cmd ├── config.go ├── config_cat.go ├── config_repo.go ├── config_repo_ls.go ├── config_repo_save.go ├── config_repo_show.go ├── label.go ├── label_copy.go ├── label_delete.go ├── label_update.go ├── repo.go ├── root.go ├── update.go ├── version.go └── viper.go ├── gitlab ├── client.go ├── globals_test.go ├── labels.go ├── labels_test.go ├── projects.go ├── projects_test.go └── utils.go └── main.go /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig coding styles definitions. For more information about the 2 | # properties used in this file, please see the EditorConfig documentation: 3 | # http://editorconfig.org/ 4 | 5 | # indicate this is the root of the project 6 | root = true 7 | 8 | [*] 9 | charset = utf-8 10 | indent_style = space 11 | indent_size = 4 12 | end_of_line = lf 13 | trim_trailing_whitespace = true 14 | max_line_length = 80 15 | 16 | # Matches the exact files either package.json or .travis.yml 17 | [{package.json,*.yml}] 18 | indent_size = 2 19 | 20 | [*.go] 21 | indent_style = tab 22 | insert_final_newline = true 23 | 24 | [*.md] 25 | trim_trailing_whitespace = false 26 | 27 | [Makefile] 28 | indent_style = tab 29 | 30 | [COMMIT_EDITMSG] 31 | max_line_length = 72 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.swp 3 | /tmp/ 4 | .idea 5 | build/gitlab-* -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "vendor/github.com/xanzy/go-gitlab"] 2 | path = vendor/github.com/xanzy/go-gitlab 3 | url = https://github.com/xanzy/go-gitlab 4 | [submodule "vendor/github.com/mitchellh/go-homedir"] 5 | path = vendor/github.com/mitchellh/go-homedir 6 | url = https://github.com/mitchellh/go-homedir 7 | [submodule "vendor/github.com/google/go-github"] 8 | path = vendor/github.com/google/go-github 9 | url = https://github.com/google/go-github 10 | [submodule "vendor/github.com/howeyc/gopass"] 11 | path = vendor/github.com/howeyc/gopass 12 | url = https://github.com/howeyc/gopass 13 | [submodule "vendor/github.com/inconshreveable/go-update"] 14 | path = vendor/github.com/inconshreveable/go-update 15 | url = https://github.com/inconshreveable/go-update 16 | [submodule "vendor/github.com/spf13/cobra"] 17 | path = vendor/github.com/spf13/cobra 18 | url = https://github.com/spf13/cobra 19 | [submodule "vendor/github.com/spf13/viper"] 20 | path = vendor/github.com/spf13/viper 21 | url = https://github.com/spf13/viper 22 | [submodule "vendor/github.com/google/go-querystring"] 23 | path = vendor/github.com/google/go-querystring 24 | url = https://github.com/google/go-querystring 25 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | sudo: required 4 | 5 | services: 6 | - docker 7 | 8 | before_install: 9 | - docker pull gitlab/gitlab-ce 10 | - docker run -d --name gitlab -p 80:80 gitlab/gitlab-ce 11 | - go get -d ./... 12 | - sleep 120 # GitLab is like a sloth 13 | 14 | install: | 15 | docker exec gitlab runuser -l gitlab-psql -c '/opt/gitlab/embedded/bin/psql --port 5432 -h /var/opt/gitlab/postgresql -d gitlabhq_production -c " 16 | INSERT INTO labels (title, color, template, description, description_html) VALUES ('"'"'feature'"'"', '"'"'#000000'"'"', true, '"'"'represents a feature'"'"', '"'"'represents a feature'"'"'); 17 | INSERT INTO labels (title, color, template, description, description_html) VALUES ('"'"'bug'"'"', '"'"'#ff0000'"'"', true, '"'"'represents a bug'"'"', '"'"'represents a bug'"'"'); 18 | UPDATE users SET authentication_token='"'"'secret'"'"' WHERE username='"'"'root'"'"';"' 19 | 20 | script: 21 | - GITLAB_URL="http://127.0.0.1" GITLAB_TOKEN="secret" go test -v ./gitlab 22 | 23 | go: 24 | - 1.7 25 | - 1.8 26 | - tip 27 | 28 | matrix: 29 | allow_failures: 30 | - go: tip -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Calin Seciu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gitlab-cli [![Build Status](https://travis-ci.org/clns/gitlab-cli.svg?branch=master)](https://travis-ci.org/clns/gitlab-cli) 2 | 3 | CLI commands for performing actions against GitLab repositories. 4 | 5 | - [Installation](#installation) 6 | - [Usage](#usage) 7 | - [Labels](#labels) 8 | - [Copy global labels](#copy-global-labels-into-a-repository) 9 | - [Copy labels from repoA to repoB](#copy-labels-from-repoa-to-repob) 10 | - [Update labels](#update-labels-that-match-a-regex) 11 | - [Delete labels](#delete-labels-that-match-a-regex) 12 | - [Specifying a repository](#specifying-a-repository) 13 | - [The config file](#the-config-file) 14 | - [Development](#development) 15 | 16 | ## Installation 17 | 18 | Follow the instructions from the [releases page](https://github.com/clns/gitlab-cli/releases). 19 | 20 | ## Usage 21 | 22 | For all available commands see the command's help: `gitlab-cli -h`. The most common commands are documented below. 23 | 24 | ### Labels 25 | 26 | #### Copy global labels into a repository 27 | 28 | GitLab Limitation: Currently there's no way to [access global labels through the API](https://twitter.com/gitlab/status/724619173477924865), so this tool provides a workaround to copy them. 29 | 30 | ```sh 31 | gitlab-cli label copy -U https://gitlab.com// -t 32 | ``` 33 | 34 | > Tip: To avoid specifying `-U` and `-t` every time you refer to a repository, you can use the config file to save the details of it. See [Specifying a repository](#specifying-a-repository). 35 | 36 | #### Copy labels from repoA to repoB 37 | 38 | ```sh 39 | gitlab-cli label copy --from -r 40 | ``` 41 | 42 | repoA and repoB are repository names saved in the [config file](#specifying-a-repository). 43 | 44 | > Tip: For repositories on the same installation, you can specify the `--from` repo as `group/repo`, as a convenience, in which case the repository is considered on the same GitLab instance as the target repo. 45 | 46 | #### Update labels that match a regex 47 | 48 | ```sh 49 | gitlab-cli label update -r --match --name --color --description 50 | ``` 51 | 52 | > Note: `` is a Go regex string as in and `` is a replacement string as in . 53 | 54 | #### Delete labels that match a regex 55 | 56 | ```sh 57 | gitlab-cli label delete -r --match 58 | ``` 59 | 60 | ### TODO 61 | 62 | Other commands can be added as needed. Feel free to open pull requests or issues. 63 | 64 | ### Specifying a repository 65 | 66 | There are 2 ways to specify a repository: 67 | 68 | 1. By using the `--url (-U)` and `--token (-t)` flags (or `--user (-u)` and `--password (-p)` instead of token) with each command. This is the easiest to get started but requires a lot of typing. 69 | 2. By saving the repository details in the config file and referring to it by its saved name using `--repo (-r)` (e.g. `-r myrepo`) 70 | 71 | Example: 72 | 73 | Instead of this: 74 | 75 | ```sh 76 | gitlab-cli label copy -U https://git.my-site.com/my_group/my_repo -t ghs93hska 77 | ``` 78 | 79 | you can first save the repo in the config file and refer to it by name on all subsequent commands: 80 | 81 | ```sh 82 | gitlab-cli config repo save -r myrepo -U https://git.my-site.com/my_group/my_repo -t ghs93hska 83 | gitlab-cli label copy -r myrepo 84 | ``` 85 | 86 | #### Using user and password instead of token 87 | 88 | You can specify your GitLab login (user or email) - `--user (-u)` - and password - `--password (-p)` - instead of the token in any command, if this is easier for you. Example: 89 | 90 | ```sh 91 | gitlab-cli config repo save -r myrepo -U https://git.my-site.com/my_group/my_repo -u my_user -p my_pass 92 | ``` 93 | 94 | ### The config file 95 | 96 | The default location of the config file is `$HOME/.gitlab-cli.yaml` and it is useful for saving repositories and then refer to them by their names. A sample config file looks like this: 97 | 98 | ```yaml 99 | repos: 100 | myrepo1: 101 | url: https://git.mysite.com/group/repo1 102 | token: Nahs93hdl3shjf 103 | myrepo2: 104 | url: https://git.mysite.com/group/repo2 105 | token: Nahs93hdl3shjf 106 | myother: 107 | url: https://git.myothersite.com/group/repo1 108 | token: OA23spfwuSalos 109 | ``` 110 | 111 | But there's no need to manually edit this file. Instead use the config commands to modify it (see `gitlab-cli config -h`). Some useful config commands are: 112 | 113 | - `gitlab-cli config cat` - print the entire config file contents 114 | - `gitlab-cli config repo ls` - list all saved repositories 115 | - `gitlab-cli config repo save ...` - save a repository 116 | - `gitlab-cli config repo show -r ` - show the details of a saved repository 117 | 118 | ## Development 119 | 120 | You'll need a [Go dev environment](https://golang.org/doc/install). 121 | 122 | ```sh 123 | git clone https://github.com/clns/gitlab-cli 124 | cd gitlab-cli 125 | git submodule --init update 126 | ``` 127 | 128 | ### Build 129 | 130 | ```sh 131 | go run build/build.go 132 | ``` 133 | 134 | This will build all the executables into the [build/](build) directory. 135 | 136 | ### Test 137 | 138 | You need to provide a GitLab URL and private token to be able to create temporary repositories for the tests. 139 | 140 | ```sh 141 | GITLAB_URL="" GITLAB_TOKEN="" go test -v ./gitlab 142 | ``` 143 | 144 | You can spin up a GitLab instance using [Docker](https://www.docker.com/): 145 | 146 | ```sh 147 | docker pull gitlab/gitlab-ce 148 | docker run -d --name gitlab -p 8055:80 gitlab/gitlab-ce 149 | sleep 60 # allow enough time for GitLab to start 150 | docker exec -ti gitlab bash 151 | su gitlab-psql 152 | /opt/gitlab/embedded/bin/psql --port 5432 -h /var/opt/gitlab/postgresql -d gitlabhq_production -c " \ 153 | INSERT INTO labels (title, color, template, description, description_html) VALUES ('feature', '#000000', true, 'represents a feature', 'represents a feature'); \ 154 | INSERT INTO labels (title, color, template, description, description_html) VALUES ('bug', '#ff0000', true, 'represents a bug', 'represents a bug'); \ 155 | UPDATE users SET authentication_token='secret' WHERE username='root';" 156 | 157 | # Note: you may need to change GITLAB_URL to point to your docker container. 158 | # 'http://docker' is for Docker beta for Windows. 159 | GITLAB_URL="http://localhost:8055" GITLAB_TOKEN="secret" go test -v ./gitlab 160 | ``` 161 | 162 | ### Vendored dependencies 163 | 164 | All external dependencies should be available in the [vendor/](vendor) directory. 165 | 166 | To list all dependencies run `go list -f '{{.ImportPath}}:{{"\n"}} {{join .Imports "\n "}}' ./...`. 167 | 168 | To vendor a package run `git submodule add https://github.com/google/go-github vendor/github.com/google/go-github`. -------------------------------------------------------------------------------- /build/build.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "strings" 9 | ) 10 | 11 | type output struct { 12 | GOOS string 13 | GOARCH string 14 | File string 15 | } 16 | 17 | var outputs = []*output{ 18 | &output{"windows", "amd64", "gitlab-cli-Windows-x86_64.exe"}, 19 | &output{"linux", "amd64", "gitlab-cli-Linux-x86_64"}, 20 | &output{"darwin", "amd64", "gitlab-cli-Darwin-x86_64"}, 21 | } 22 | 23 | func main() { 24 | for _, o := range outputs { 25 | vars := []string{"GOOS=" + o.GOOS, "GOARCH=" + o.GOARCH} 26 | fmt.Fprintf(os.Stdout, "%s go build -o build/%s main.go ...", strings.Join(vars, " "), o.File) 27 | cmd := exec.Command("go", "build", "-o", filepath.Join("build", o.File), "main.go") 28 | cmd.Stdout = os.Stdout 29 | cmd.Stderr = os.Stderr 30 | env := os.Environ() 31 | env = append(env, vars...) 32 | cmd.Env = env 33 | if err := cmd.Run(); err != nil { 34 | fmt.Fprintln(os.Stderr, err) 35 | } 36 | fmt.Fprintln(os.Stdout, "done") 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /cmd/config.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import "github.com/spf13/cobra" 4 | 5 | var configCmd = &cobra.Command{ 6 | Use: "config", 7 | Short: "Config actions", 8 | Long: `Perform config-related actions.`, 9 | } 10 | 11 | func init() { 12 | RootCmd.AddCommand(configCmd) 13 | } 14 | -------------------------------------------------------------------------------- /cmd/config_cat.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "io/ioutil" 7 | "os" 8 | 9 | "github.com/spf13/cobra" 10 | "github.com/spf13/viper" 11 | ) 12 | 13 | var configCatCmd = &cobra.Command{ 14 | Use: "cat", 15 | Short: "Print config file", 16 | Run: func(cmd *cobra.Command, args []string) { 17 | b, err := ioutil.ReadFile(viper.ConfigFileUsed()) 18 | if err != nil { 19 | os.Exit(1) 20 | } 21 | fmt.Fprintln(os.Stdout, string(b)) 22 | }, 23 | } 24 | 25 | func init() { 26 | configCmd.AddCommand(configCatCmd) 27 | } 28 | -------------------------------------------------------------------------------- /cmd/config_repo.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import "github.com/spf13/cobra" 4 | 5 | var configRepoCmd = &cobra.Command{ 6 | Use: "repo", 7 | Short: "Config repos actions", 8 | Long: `Perform actions on the repos from the config file.`, 9 | } 10 | 11 | func init() { 12 | configCmd.AddCommand(configRepoCmd) 13 | } 14 | -------------------------------------------------------------------------------- /cmd/config_repo_ls.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | "github.com/spf13/viper" 8 | ) 9 | 10 | var configRepoLsCmd = &cobra.Command{ 11 | Use: "ls", 12 | Short: "List repositories from config file", 13 | Run: func(cmd *cobra.Command, args []string) { 14 | for name, _ := range viper.GetStringMap("repos") { 15 | r := LoadFromConfigNoInit(name) 16 | fmt.Println(r.String()) 17 | } 18 | }, 19 | } 20 | 21 | func init() { 22 | configRepoCmd.AddCommand(configRepoLsCmd) 23 | } 24 | -------------------------------------------------------------------------------- /cmd/config_repo_save.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "os" 7 | 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | var configRepoSaveCmd = &cobra.Command{ 12 | Use: "save", 13 | Short: "Save repo into the config file", 14 | Example: ` $ gitlab config repo save -r myrepo -U https://gitlan.com/user/repo -t `, 15 | Run: func(cmd *cobra.Command, args []string) { 16 | if repo == "" { 17 | fmt.Fprintf(os.Stderr, "error: no repo name given\n") 18 | os.Exit(1) 19 | } 20 | r, err := LoadFromConfig(repo) 21 | if err != nil { 22 | fmt.Fprintf(os.Stderr, "error: invalid repository: %v\n", err) 23 | os.Exit(1) 24 | } 25 | r.Name = repo 26 | if err := r.SaveToConfig(); err != nil { 27 | fmt.Fprintf(os.Stderr, "error: %v\n", err) 28 | os.Exit(1) 29 | } 30 | if err := SaveViperConfig(); err != nil { 31 | fmt.Fprintf(os.Stderr, "error: %v\n", err) 32 | os.Exit(1) 33 | } 34 | }, 35 | } 36 | 37 | func init() { 38 | configRepoCmd.AddCommand(configRepoSaveCmd) 39 | } 40 | -------------------------------------------------------------------------------- /cmd/config_repo_show.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "os" 7 | 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | var configRepoShowCmd = &cobra.Command{ 12 | Use: "show", 13 | Short: "Show repo info from the config file", 14 | Example: ` $ gitlab config repo show -r myrepo`, 15 | Run: func(cmd *cobra.Command, args []string) { 16 | if repo == "" { 17 | fmt.Fprintf(os.Stderr, "error: no repo name given\n") 18 | os.Exit(1) 19 | } 20 | r, err := LoadFromConfig(repo) 21 | if err != nil { 22 | fmt.Fprintf(os.Stderr, "error: invalid repository: %v\n", err) 23 | os.Exit(1) 24 | } 25 | fmt.Println(r.String()) 26 | }, 27 | } 28 | 29 | func init() { 30 | configRepoCmd.AddCommand(configRepoShowCmd) 31 | } 32 | -------------------------------------------------------------------------------- /cmd/label.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import "github.com/spf13/cobra" 4 | 5 | var labelCmd = &cobra.Command{ 6 | Use: "label", 7 | Aliases: []string{"l"}, 8 | Short: "Label actions", 9 | Long: `Perform actions on labels.`, 10 | } 11 | 12 | func init() { 13 | RootCmd.AddCommand(labelCmd) 14 | } 15 | -------------------------------------------------------------------------------- /cmd/label_copy.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var fromRepo string 11 | 12 | var labelCopyCmd = &cobra.Command{ 13 | Use: "copy", 14 | Aliases: []string{"c"}, 15 | Short: "Copy labels into a repository", 16 | Long: `Copy labels into a repository. 17 | 18 | If --from is omitted, it will copy global labels. If --from is specified, 19 | it will copy all labels from that repository. 20 | 21 | The from repo can be a repo name as in the config file or a relative path 22 | as group/repo (e.g. 'myuser/myrepo'). In the later case it will use the url 23 | of the target repo, so the repositories need to be on the same GitLab instance.`, 24 | Example: ` $ gitlab label copy -U https://gitlab.com/user/myrepo -t 25 | $ gitlab label copy --from sourceRepo -r targetRepo 26 | $ gitlab label copy --from group/repo -r targetRepo`, 27 | Run: func(cmd *cobra.Command, args []string) { 28 | var ( 29 | from, to *Repo 30 | err error 31 | ) 32 | if to, err = LoadFromConfig(repo); err != nil { 33 | fmt.Fprintf(os.Stderr, "error: invalid target repository: %v\n", err.Error()) 34 | os.Exit(1) 35 | } 36 | if fromRepo != "" { 37 | if from, err = LoadFromConfig(fromRepo); err != nil { 38 | fmt.Fprintf(os.Stderr, "error: invalid source repository: %v\n", err.Error()) 39 | os.Exit(1) 40 | } 41 | } 42 | 43 | if from == nil { 44 | // we need to copy the global labels 45 | if err := to.Client.Labels.CopyGlobalLabelsTo(to.Project.ID); err != nil { 46 | fmt.Fprintf(os.Stderr, "error: '%s': %v\n", 47 | to.Project.PathWithNamespace, err) 48 | os.Exit(1) 49 | } 50 | } else { 51 | // we need to copy labels from one project to another 52 | if err := to.Client.Labels.CopyLabels(from.Project.ID, to.Project.ID); err != nil { 53 | fmt.Fprintf(os.Stderr, "error: '%s' to '%s': %v\n", 54 | from.Project.PathWithNamespace, to.Project.PathWithNamespace, err) 55 | os.Exit(1) 56 | } 57 | } 58 | }, 59 | } 60 | 61 | func init() { 62 | labelCmd.AddCommand(labelCopyCmd) 63 | 64 | labelCopyCmd.Flags().StringVar(&fromRepo, "from", "", "Source repository (optional)") 65 | } 66 | -------------------------------------------------------------------------------- /cmd/label_delete.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var regexpLabel string 11 | 12 | var labelDeleteCmd = &cobra.Command{ 13 | Use: "delete", 14 | Aliases: []string{"d"}, 15 | Short: "Delete labels from a repository", 16 | Long: `Delete labels from a repository. 17 | 18 | The --match flag can be specified as a Go regexp pattern to delete only 19 | labels that match. If ommitted, all repository labels will be deleted.`, 20 | Example: ` $ gitlab label delete -r myrepo 21 | $ gitlab label delete -r myrepo --match=".*:.*"`, 22 | Run: func(cmd *cobra.Command, args []string) { 23 | var ( 24 | to *Repo 25 | err error 26 | ) 27 | if to, err = LoadFromConfig(repo); err != nil { 28 | fmt.Fprintf(os.Stderr, "error: invalid repository: %v\n", err.Error()) 29 | os.Exit(1) 30 | } 31 | 32 | if err := to.Client.Labels.DeleteWithRegex(to.Project.ID, regexpLabel); err != nil { 33 | fmt.Fprintf(os.Stderr, "error: %v\n", err.Error()) 34 | os.Exit(1) 35 | } 36 | }, 37 | } 38 | 39 | func init() { 40 | labelCmd.AddCommand(labelDeleteCmd) 41 | 42 | labelDeleteCmd.Flags().StringVar(®expLabel, "match", "", "Label name to match, as a Go regex (https://golang.org/pkg/regexp/syntax)") 43 | } 44 | -------------------------------------------------------------------------------- /cmd/label_update.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/spf13/cobra" 8 | gogitlab "github.com/xanzy/go-gitlab" 9 | ) 10 | 11 | var matchLabel string 12 | var replaceLabel string 13 | var colorLabel string 14 | var descriptionLabel string 15 | 16 | var labelUpdateCmd = &cobra.Command{ 17 | Use: "update", 18 | Aliases: []string{"u"}, 19 | Short: "Update labels in a repository", 20 | Long: `Update labels in a repository. 21 | 22 | The --match flag is required and is a Go regex that will be used to match the label 23 | name. At least one of --name, --color or --description is required to update the label(s).`, 24 | Example: ` $ gitlab label update -r myrepo --match "(.*):(.*)" --name "${1}/${2}"`, 25 | Run: func(cmd *cobra.Command, args []string) { 26 | var ( 27 | to *Repo 28 | err error 29 | ) 30 | if to, err = LoadFromConfig(repo); err != nil { 31 | fmt.Fprintf(os.Stderr, "error: invalid repository: %v\n", err.Error()) 32 | os.Exit(1) 33 | } 34 | 35 | if err := to.Client.Labels.UpdateWithRegex(to.Project.ID, &gogitlab.UpdateLabelOptions{ 36 | Name: &matchLabel, 37 | NewName: &replaceLabel, 38 | Color: &colorLabel, 39 | Description: &descriptionLabel, 40 | }); err != nil { 41 | fmt.Fprintf(os.Stderr, "error: %v\n", err.Error()) 42 | os.Exit(1) 43 | } 44 | }, 45 | } 46 | 47 | func init() { 48 | labelCmd.AddCommand(labelUpdateCmd) 49 | 50 | labelUpdateCmd.Flags().StringVar(&matchLabel, "match", "", "Label name to match, as a Go regex (https://golang.org/pkg/regexp/syntax)") 51 | labelUpdateCmd.Flags().StringVar(&replaceLabel, "name", "", "Label name (https://golang.org/pkg/regexp/#Regexp.FindAllString)") 52 | labelUpdateCmd.Flags().StringVar(&colorLabel, "color", "", "Label color (e.g. '#000000')") 53 | labelUpdateCmd.Flags().StringVar(&descriptionLabel, "description", "", "Label description") 54 | } 55 | -------------------------------------------------------------------------------- /cmd/repo.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "net/url" 5 | 6 | "fmt" 7 | "strings" 8 | 9 | "os" 10 | 11 | "github.com/clns/gitlab-cli/gitlab" 12 | "github.com/howeyc/gopass" 13 | "github.com/spf13/viper" 14 | gogitlab "github.com/xanzy/go-gitlab" 15 | ) 16 | 17 | // Repo represents a cli repository. 18 | type Repo struct { 19 | Client *gitlab.Client 20 | Project *gogitlab.Project 21 | Name string 22 | Url_ string `mapstructure:"url"` 23 | URL *url.URL 24 | Token string `mapstructure:"token"` 25 | } 26 | 27 | type repoMap struct { 28 | URL string `mapstructure:"url"` 29 | Token string `mapstructure:"token"` 30 | } 31 | 32 | func LoadFromConfig(namepath string) (*Repo, error) { 33 | r := LoadFromConfigNoInit(namepath) 34 | if err := r.initialize(); err != nil { 35 | return nil, err 36 | } 37 | return r, nil 38 | } 39 | 40 | func LoadFromConfigNoInit(namepath string) *Repo { 41 | key := "repos." + namepath 42 | r := &Repo{ 43 | Url_: viper.GetString("_url"), 44 | Token: viper.GetString("_token"), 45 | } 46 | if viper.IsSet(key) { 47 | viper.UnmarshalKey(key, r) 48 | r.Name = namepath 49 | } else if namepath != "" { 50 | if r.URL, _ = url.Parse(r.Url_); r.URL != nil { 51 | r.URL.Path = namepath 52 | } 53 | } 54 | return r 55 | } 56 | 57 | func (r *Repo) String() string { 58 | return fmt.Sprintf(`%s 59 | url: %s 60 | token: %s`, r.Name, r.Url_, r.Token) 61 | } 62 | 63 | func (r *Repo) SaveToConfig() error { 64 | if r.Name == "" { 65 | return fmt.Errorf("cannot save to config without a name") 66 | } 67 | repos := make(map[string]*repoMap) 68 | for name, _ := range viper.GetStringMap("repos") { 69 | var rep *repoMap 70 | if err := viper.UnmarshalKey("repos."+name, &rep); err != nil { 71 | fmt.Fprintln(os.Stderr, err) 72 | } 73 | repos[name] = rep 74 | } 75 | repos[r.Name] = &repoMap{ 76 | URL: r.URL.String(), 77 | Token: r.Token, 78 | } 79 | viper.Set("repos", repos) 80 | 81 | return nil 82 | } 83 | 84 | func (r *Repo) initialize() error { 85 | var err error 86 | r.URL, err = url.Parse(r.Url_) 87 | if err != nil { 88 | return fmt.Errorf("invalid repo url: %v", err) 89 | } 90 | if r.URL.String() == "" { 91 | return fmt.Errorf("empty repo url") 92 | } 93 | if r.URL.Path == "" || strings.Index(r.URL.Path, "/") == -1 { 94 | return fmt.Errorf("invalid or no repo path specified") 95 | } 96 | if r.Client, err = r.client(); err != nil { 97 | return fmt.Errorf("failed to get GitLab client for repo '%s': %v", r.URL, err) 98 | } 99 | r.Token = r.Client.Token 100 | if r.Project, err = r.project(); err != nil { 101 | return fmt.Errorf("failed to get GitLab project '%s': %v", r.URL, err) 102 | } 103 | return nil 104 | } 105 | 106 | func (r *Repo) client() (*gitlab.Client, error) { 107 | u := *r.URL 108 | u.Path = "" 109 | if r.Token == "" && user != "" { 110 | if password == "" { 111 | fmt.Print("Password: ") 112 | pwd, _ := gopass.GetPasswdMasked() 113 | password = string(pwd) 114 | } 115 | return gitlab.NewClientForUser(&u, user, password) 116 | } 117 | return gitlab.NewClient(&u, r.Token) 118 | } 119 | 120 | func (r *Repo) project() (*gogitlab.Project, error) { 121 | proj, err := r.Client.Projects.ByPath(r.URL.Path) 122 | if err != nil { 123 | return nil, err 124 | } 125 | return proj, nil 126 | } 127 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "io/ioutil" 8 | "log" 9 | 10 | "github.com/spf13/cobra" 11 | "github.com/spf13/viper" 12 | ) 13 | 14 | var ( 15 | cfgFile string 16 | repo, repourl, token string 17 | user, password string 18 | verbose bool 19 | configName = ".gitlab-cli" 20 | ) 21 | 22 | // RootCmd represents the base command when called without any subcommands 23 | var RootCmd = &cobra.Command{ 24 | Use: "gitlab-cli", 25 | Short: "Cli tool for performing actions against GitLab repositories", 26 | // Uncomment the following line if your bare application 27 | // has an action associated with it: 28 | // Run: func(cmd *cobra.Command, args []string) { }, 29 | } 30 | 31 | // Execute adds all child commands to the root command sets flags appropriately. 32 | // This is called by main.main(). It only needs to happen once to the rootCmd. 33 | func Execute() { 34 | if !verbose { 35 | log.SetOutput(ioutil.Discard) 36 | } 37 | CheckUpdate() 38 | if err := RootCmd.Execute(); err != nil { 39 | fmt.Println(err) 40 | os.Exit(-1) 41 | } 42 | } 43 | 44 | func init() { 45 | cobra.OnInitialize(initConfig) 46 | 47 | // Here you will define your flags and configuration settings. 48 | // Cobra supports Persistent Flags, which, if defined here, 49 | // will be global for your application. 50 | 51 | RootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.gitlab-cli.yaml)") 52 | 53 | // Repository flags 54 | RootCmd.PersistentFlags().StringVarP(&repo, "repo", "r", "", "repo name (as in the config file)") 55 | RootCmd.PersistentFlags().StringVarP(&repourl, "url", "U", "", "repository URL, including the path (e.g. https://mygitlab.com/group/repo)") 56 | RootCmd.PersistentFlags().StringVarP(&token, "token", "t", "", "GitLab token (see http://doc.gitlab.com/ce/api/#authentication)") 57 | RootCmd.PersistentFlags().StringVarP(&user, "user", "u", "", "GitLab login (user or email), if no token provided") 58 | RootCmd.PersistentFlags().StringVarP(&password, "password", "p", "", "GitLab password, if no token provided (if empty, will prompt)") 59 | RootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "print logs") 60 | 61 | viper.BindPFlag("_url", RootCmd.PersistentFlags().Lookup("url")) 62 | viper.BindPFlag("_token", RootCmd.PersistentFlags().Lookup("token")) 63 | } 64 | 65 | // initConfig reads in config file and ENV variables if set. 66 | func initConfig() { 67 | if cfgFile != "" { // enable ability to specify config file via flag 68 | viper.SetConfigFile(cfgFile) 69 | } 70 | 71 | viper.SetConfigName(configName) // name of config file (without extension) 72 | viper.AddConfigPath("$HOME") // adding home directory as first search path 73 | viper.AutomaticEnv() // read in environment variables that match 74 | viper.ConfigFileUsed() 75 | 76 | // If a config file is found, read it in. 77 | if err := viper.ReadInConfig(); err == nil { 78 | fmt.Println("Using config file:", viper.ConfigFileUsed()) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /cmd/update.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "net/http" 9 | "os" 10 | "path/filepath" 11 | "runtime" 12 | "time" 13 | 14 | "github.com/google/go-github/github" 15 | "github.com/inconshreveable/go-update" 16 | "github.com/spf13/cobra" 17 | ) 18 | 19 | var updateCmd = &cobra.Command{ 20 | Use: "update", 21 | Aliases: []string{"u"}, 22 | Short: "Update this tool to the latest version", 23 | Long: `Update this tool to the latest version. 24 | 25 | You might need to run this command with sudo.`, 26 | Run: func(cmd *cobra.Command, args []string) { 27 | rel, err := getLatestRelease() 28 | if err != nil { 29 | fmt.Fprintln(os.Stderr, "error: failed to retrieve the latest release", err) 30 | os.Exit(2) 31 | } 32 | if *rel.TagName == Version { 33 | fmt.Fprintln(os.Stdout, "No update available, latest version is", Version) 34 | os.Exit(0) 35 | } 36 | 37 | fmt.Printf("New update available: %s. Your current version is %s.\n", *rel.TagName, Version) 38 | 39 | asset, err := getReleaseAsset(rel) 40 | if err != nil { 41 | fmt.Fprintln(os.Stderr, err) 42 | os.Exit(2) 43 | } 44 | 45 | // Download asset 46 | fmt.Printf("downloading %d MB...\n", *asset.Size/1024/1024) 47 | req, err := http.NewRequest("GET", *asset.BrowserDownloadURL, nil) 48 | if err != nil { 49 | fmt.Fprintln(os.Stderr, err) 50 | os.Exit(2) 51 | } 52 | req.Header.Add("Accept", "application/octet-stream") 53 | c := &http.Client{Timeout: 30 * time.Second} 54 | resp, err := c.Do(req) 55 | if err != nil { 56 | fmt.Fprintln(os.Stderr, err) 57 | os.Exit(2) 58 | } 59 | defer resp.Body.Close() 60 | if resp.StatusCode != 200 { 61 | b, _ := ioutil.ReadAll(resp.Body) 62 | fmt.Fprintf(os.Stderr, "%s: %s", resp.Status, b) 63 | os.Exit(2) 64 | } 65 | 66 | // Update 67 | if err := update.Apply(resp.Body, update.Options{}); err != nil { 68 | fmt.Fprintln(os.Stderr, err) 69 | os.Exit(2) 70 | } 71 | 72 | fmt.Println("gitlab-cli updated successfully to", *rel.TagName) 73 | saveVersion(*rel.TagName) 74 | }, 75 | } 76 | 77 | func init() { 78 | RootCmd.AddCommand(updateCmd) 79 | } 80 | 81 | // Update functions 82 | 83 | var lastUpdateCheck = filepath.Join(os.TempDir(), "gitlab-cli-latest-release") 84 | 85 | func shouldCheckForUpdate() bool { 86 | fi, err := os.Stat(lastUpdateCheck) 87 | return !(err == nil && time.Now().Sub(fi.ModTime()) < 2*time.Minute) 88 | } 89 | 90 | func getLatestRelease() (rel *github.RepositoryRelease, err error) { 91 | gh := github.NewClient(&http.Client{Timeout: 1 * time.Second}) 92 | rel, _, err = gh.Repositories.GetLatestRelease(context.Background(), "clns", "gitlab-cli") 93 | return 94 | } 95 | 96 | // getReleaseAsset returns the platform-specific asset of the release. 97 | // Be careful because it can be nil if not found. 98 | func getReleaseAsset(rel *github.RepositoryRelease) (*github.ReleaseAsset, error) { 99 | var file string 100 | switch runtime.GOOS { 101 | case "windows": 102 | file = "gitlab-cli-Windows-x86_64.exe" 103 | case "linux": 104 | file = "gitlab-cli-Linux-x86_64" 105 | case "darwin": 106 | file = "gitlab-cli-Darwin-x86_64" 107 | default: 108 | return nil, fmt.Errorf("Unsupported platform") 109 | } 110 | for _, a := range rel.Assets { 111 | if *a.Name == file { 112 | return &a, nil 113 | } 114 | } 115 | return nil, fmt.Errorf("Binary not found for your platform") 116 | } 117 | 118 | func CheckUpdate() { 119 | if !shouldCheckForUpdate() { 120 | log.Println("update: skip checking") 121 | b, err := ioutil.ReadFile(lastUpdateCheck) 122 | if err == nil && len(b) > 0 { 123 | printUpdateAvl(string(b)) 124 | } 125 | return 126 | } 127 | rel, err := getLatestRelease() 128 | defer func() { 129 | ver := "" 130 | if rel != nil { 131 | ver = *rel.TagName 132 | } 133 | saveVersion(ver) 134 | }() 135 | if err != nil { 136 | log.Println("update:", err) 137 | return 138 | } 139 | printUpdateAvl(*rel.TagName) 140 | } 141 | 142 | func printUpdateAvl(latest string) { 143 | if latest != Version { 144 | if runtime.GOOS == "linux" { 145 | fmt.Printf("New update available: %s. Run 'sudo gitlab-cli update' to update.\n", latest) 146 | } else { 147 | fmt.Printf("New update available: %s. Run 'gitlab-cli update' to update.\n", latest) 148 | } 149 | } 150 | } 151 | 152 | func saveVersion(ver string) { 153 | if err := ioutil.WriteFile(lastUpdateCheck, []byte(ver), os.ModePerm); err != nil { 154 | log.Println("update:", err) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | const Version = "0.3.2" 10 | 11 | // versionCmd represents the version command 12 | var versionCmd = &cobra.Command{ 13 | Use: "version", 14 | Aliases: []string{"v"}, 15 | Short: "Print the version of this tool", 16 | Run: func(cmd *cobra.Command, args []string) { 17 | fmt.Printf("gitlab-cli %v\n", Version) 18 | }, 19 | } 20 | 21 | func init() { 22 | RootCmd.AddCommand(versionCmd) 23 | } 24 | -------------------------------------------------------------------------------- /cmd/viper.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/mitchellh/go-homedir" 10 | "github.com/spf13/viper" 11 | "gopkg.in/yaml.v2" 12 | ) 13 | 14 | func SaveViperConfig() error { 15 | filename := viper.ConfigFileUsed() 16 | if filename == "" { 17 | hdir, err := homedir.Dir() 18 | if err != nil { 19 | return err 20 | } 21 | filename = filepath.Join(hdir, configName+".yml") 22 | } 23 | f, err := os.Create(filename) 24 | if err != nil { 25 | return err 26 | } 27 | defer f.Close() 28 | 29 | all := viper.AllSettings() 30 | for k, _ := range all { 31 | if strings.HasPrefix(k, "_") { 32 | delete(all, k) 33 | } 34 | } 35 | b, err := yaml.Marshal(all) 36 | if err != nil { 37 | return fmt.Errorf("Panic while encoding into YAML format.") 38 | } 39 | if _, err := f.WriteString(string(b)); err != nil { 40 | return err 41 | } 42 | return nil 43 | } 44 | -------------------------------------------------------------------------------- /gitlab/client.go: -------------------------------------------------------------------------------- 1 | package gitlab 2 | 3 | import ( 4 | "crypto/tls" 5 | "net/http" 6 | "net/url" 7 | "time" 8 | 9 | gogitlab "github.com/xanzy/go-gitlab" 10 | ) 11 | 12 | const GitLabAPI = "/api/v3/" 13 | 14 | // Client is a wrapper for the go-gitlab.Client object that provides 15 | // additional methods and initializes with a URL. 16 | type Client struct { 17 | *gogitlab.Client 18 | Token string 19 | 20 | Projects *Projects 21 | Labels *Labels 22 | } 23 | 24 | // NewClient returns a Client object that can be used to make API calls. 25 | // If instead of token you have username and password, you should use 26 | // NewClientForUser(). 27 | func NewClient(uri *url.URL, token string) (*Client, error) { 28 | c := &Client{ 29 | Client: getClient(token), 30 | Token: token, 31 | } 32 | if err := c.Client.SetBaseURL(uri.String() + GitLabAPI); err != nil { 33 | return nil, err 34 | } 35 | 36 | c.Projects = &Projects{c.Client.Projects, c} 37 | c.Labels = &Labels{c.Client.Labels, c} 38 | 39 | return c, nil 40 | } 41 | 42 | // NewClientForUser is the same as NewClient but uses an user instead 43 | // of a private token to authenticate. 44 | func NewClientForUser(uri *url.URL, user, pass string) (*Client, error) { 45 | c, err := NewClient(uri, "") 46 | if err != nil { 47 | return nil, err 48 | } 49 | t, err := c.getTokenForUser(user, pass) 50 | if err != nil { 51 | return nil, err 52 | } 53 | return NewClient(uri, t) 54 | } 55 | 56 | // getTokenForUser returns the token for the given user. 57 | func (c *Client) getTokenForUser(user, pass string) (string, error) { 58 | sess, _, err := c.Client.Session.GetSession(&gogitlab.GetSessionOptions{ 59 | Login: &user, 60 | Password: &pass, 61 | }) 62 | if err != nil { 63 | return "", err 64 | } 65 | return sess.PrivateToken, nil 66 | } 67 | 68 | // getClient returns a gitlab client with a timeout and https check disabled. 69 | // Before using it, you should call SetBaseURL() to set the GitLab url. 70 | func getClient(token string) *gogitlab.Client { 71 | tr := &http.Transport{ 72 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 73 | } 74 | client := &http.Client{Transport: tr, Timeout: 5 * time.Minute} 75 | return gogitlab.NewClient(client, token) 76 | } 77 | -------------------------------------------------------------------------------- /gitlab/globals_test.go: -------------------------------------------------------------------------------- 1 | package gitlab 2 | 3 | import ( 4 | "net/url" 5 | "os" 6 | "path" 7 | "runtime" 8 | "strings" 9 | "testing" 10 | 11 | gogitlab "github.com/xanzy/go-gitlab" 12 | ) 13 | 14 | var ( 15 | GitLabURI = os.Getenv("GITLAB_URL") 16 | GitLabToken = os.Getenv("GITLAB_TOKEN") 17 | GitLabAPIURL *url.URL 18 | GitLabClient *Client 19 | ) 20 | 21 | func init() { 22 | var err error 23 | if GitLabAPIURL, err = url.Parse(strings.TrimSuffix(GitLabURI, "/")); err != nil { 24 | panic(err) 25 | } 26 | 27 | if GitLabClient, err = NewClient(GitLabAPIURL, GitLabToken); err != nil { 28 | panic(err) 29 | } 30 | } 31 | 32 | func before(tb testing.TB) { 33 | if GitLabURI == "" { 34 | tb.Skip("GITLAB_URL is not set, should be set in order to run tests (e.g. 'https://gitlab.com')") 35 | } 36 | if GitLabToken == "" { 37 | tb.Skip("GITLAB_TOKEN is not set, should be set in order to run tests") 38 | } 39 | } 40 | 41 | // creates a minimal gitlab project with a random string appended 42 | // to the given name. 43 | func createProject(tb testing.TB, name, desc string) *gogitlab.Project { 44 | n := name + RandomString(4) 45 | proj, _, err := GitLabClient.Projects.CreateProject(&gogitlab.CreateProjectOptions{ 46 | Name: &n, 47 | Description: &desc, 48 | }) 49 | if err != nil { 50 | // The failure happens at wherever we were called, not here 51 | _, file, line, ok := runtime.Caller(1) 52 | if !ok { 53 | tb.Fatalf("Unable to get caller") 54 | } 55 | tb.Fatalf("%s:%v %v", path.Base(file), line, err) 56 | } 57 | return proj 58 | } 59 | 60 | func deleteProject(tb testing.TB, proj *gogitlab.Project) { 61 | if _, err := GitLabClient.Projects.DeleteProject(proj.ID); err != nil { 62 | // The failure happens at wherever we were called, not here 63 | _, file, line, ok := runtime.Caller(1) 64 | if !ok { 65 | tb.Fatalf("Unable to get caller") 66 | } 67 | tb.Errorf("%s:%v %v", path.Base(file), line, err) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /gitlab/labels.go: -------------------------------------------------------------------------------- 1 | package gitlab 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | 7 | "strings" 8 | 9 | "os" 10 | 11 | gogitlab "github.com/xanzy/go-gitlab" 12 | ) 13 | 14 | type Labels struct { 15 | *gogitlab.LabelsService 16 | client *Client 17 | } 18 | 19 | // UpdateWithRegex updates label(s) by a given regex in a given project. The difference 20 | // between *LabelsService.UpdateLabel() and this is that opts.Name is a regexp string, 21 | // so you can do things like replace all labels like 'type:bug' with 'type/bug' using: 22 | // 23 | // opts.Name: "(.+):(.+)" 24 | // opts.NewName: "${1}/${2}" 25 | // 26 | // If at least one label fails to update, it will return an error. 27 | func (srv *Labels) UpdateWithRegex(pid interface{}, opts *gogitlab.UpdateLabelOptions) error { 28 | re, err := regexp.Compile(*opts.Name) 29 | if err != nil { 30 | return fmt.Errorf("'%s' is not a valid Go regexp: %v\n"+ 31 | "See https://golang.org/pkg/regexp/syntax/", opts.Name, err) 32 | } 33 | repl := opts.NewName 34 | labels, _, err := srv.ListLabels(pid) 35 | if err != nil { 36 | return err 37 | } 38 | var errs []string 39 | for _, label := range labels { 40 | if re.MatchString(label.Name) { 41 | opts.Name = &label.Name 42 | var newName string 43 | if repl != nil && *repl != "" { 44 | newName = re.ReplaceAllString(label.Name, *repl) 45 | } else { 46 | newName = "" 47 | } 48 | opts.NewName = &newName 49 | if _, _, err := srv.UpdateLabel(pid, opts); err != nil { 50 | errs = append(errs, fmt.Sprintf("'%s' failed to update: %v", label.Name, err)) 51 | } 52 | } 53 | } 54 | if len(errs) > 0 { 55 | return fmt.Errorf("failed to update (some) labels with the following errors:\n%s", strings.Join(errs, "\n")) 56 | } 57 | return nil 58 | } 59 | 60 | // DeleteWithRegex deletes labels from a project, optionally by matching 61 | // against a Regexp pattern. 62 | func (srv *Labels) DeleteWithRegex(pid interface{}, pattern string) error { 63 | re, err := regexp.Compile(pattern) 64 | if err != nil { 65 | return fmt.Errorf("'%s' is not a valid Go regexp: %v\n"+ 66 | "See https://golang.org/pkg/regexp/syntax/", pattern, err) 67 | } 68 | labels, _, err := srv.ListLabels(pid) 69 | if err != nil { 70 | return err 71 | } 72 | for _, label := range labels { 73 | if pattern == "" || re.MatchString(label.Name) { 74 | _, err := srv.DeleteLabel(pid, &gogitlab.DeleteLabelOptions{Name: &label.Name}) 75 | if err != nil { 76 | return err 77 | } 78 | } 79 | } 80 | return nil 81 | } 82 | 83 | // CopyGlobalLabelsTo copies the global labels to the given project id. 84 | // Since there's no API in GitLab for accessing global labels, it 85 | // creates a temporary project that should have all global labels copied into 86 | // and then reads the labels from it. It deletes the temporary project when done. 87 | // 88 | // If at least one label fails to copy, it will return an error. 89 | func (srv *Labels) CopyGlobalLabelsTo(pid interface{}) error { 90 | name := "temporary-copy-globals-from-" + RandomString(4) 91 | desc := "Temporary repository to copy global labels from" 92 | proj, _, err := srv.client.Projects.CreateProject(&gogitlab.CreateProjectOptions{ 93 | Name: &name, 94 | Description: &desc, 95 | }) 96 | if err != nil { 97 | return err 98 | } 99 | defer func() { 100 | if _, err := srv.client.Projects.DeleteProject(proj.ID); err != nil { 101 | fmt.Fprintln(os.Stderr, err) 102 | } 103 | }() 104 | 105 | return srv.CopyLabels(proj.ID, pid) 106 | } 107 | 108 | // CopyLabels copies the labels from a project into another one, 109 | // based on the given pid's. 110 | // 111 | // If at least one label fails to copy, it will return an error. 112 | func (srv *Labels) CopyLabels(from, to interface{}) error { 113 | labels, _, err := srv.ListLabels(from) 114 | if err != nil { 115 | return err 116 | } 117 | var errs []string 118 | for _, label := range labels { 119 | if _, _, err := srv.CreateLabel(to, &gogitlab.CreateLabelOptions{ 120 | Name: &label.Name, 121 | Color: &label.Color, 122 | Description: &label.Description, 123 | }); err != nil { 124 | errs = append(errs, fmt.Sprintf("'%s' failed to create: %v", label.Name, err)) 125 | } 126 | } 127 | if len(errs) > 0 { 128 | return fmt.Errorf("failed to copy (some) labels with the following errors:\n%s", strings.Join(errs, "\n")) 129 | } 130 | return nil 131 | } 132 | -------------------------------------------------------------------------------- /gitlab/labels_test.go: -------------------------------------------------------------------------------- 1 | package gitlab 2 | 3 | import ( 4 | "testing" 5 | 6 | "path" 7 | "runtime" 8 | 9 | "reflect" 10 | 11 | gogitlab "github.com/xanzy/go-gitlab" 12 | ) 13 | 14 | func TestLabels_UpdateWithRegex(t *testing.T) { 15 | before(t) 16 | 17 | proj := createProject(t, "temporary-update-labels-", "Temporary repository to update labels into") 18 | defer deleteProject(t, proj) 19 | 20 | addLabel(t, proj, "category#label", "#000000", "First label") 21 | addLabel(t, proj, "misc#anotherlabel", "#ff0000", "Second label") 22 | 23 | // update with regex 24 | name := "(.+)#(.+)" 25 | newName := "${1}/${2}" 26 | if err := GitLabClient.Labels.UpdateWithRegex(proj.ID, &gogitlab.UpdateLabelOptions{ 27 | Name: &name, 28 | NewName: &newName, 29 | }); err != nil { 30 | t.Fatal(err) 31 | } 32 | 33 | labelsExist(t, proj, []*gogitlab.Label{ 34 | &gogitlab.Label{"category/label", "#000000", "First label", 0, 0, 0}, 35 | &gogitlab.Label{"misc/anotherlabel", "#ff0000", "Second label", 0, 0, 0}, 36 | }) 37 | 38 | // update again without regex 39 | name = "category/label" 40 | newName = "category-label" 41 | if err := GitLabClient.Labels.UpdateWithRegex(proj.ID, &gogitlab.UpdateLabelOptions{ 42 | Name: &name, 43 | NewName: &newName, 44 | }); err != nil { 45 | t.Fatal(err) 46 | } 47 | 48 | labelsExist(t, proj, []*gogitlab.Label{ 49 | &gogitlab.Label{"category-label", "#000000", "First label", 0, 0, 0}, 50 | &gogitlab.Label{"misc/anotherlabel", "#ff0000", "Second label", 0, 0, 0}, 51 | }) 52 | 53 | // update color 54 | name = "^misc" 55 | col := "#ff7863" 56 | if err := GitLabClient.Labels.UpdateWithRegex(proj.ID, &gogitlab.UpdateLabelOptions{ 57 | Name: &name, 58 | Color: &col, 59 | }); err != nil { 60 | t.Fatal(err) 61 | } 62 | 63 | labelsExist(t, proj, []*gogitlab.Label{ 64 | &gogitlab.Label{"misc/anotherlabel", "#ff7863", "Second label", 0, 0, 0}, 65 | }) 66 | } 67 | 68 | func TestLabels_DeleteWithRegex(t *testing.T) { 69 | before(t) 70 | 71 | proj := createProject(t, "temporary-delete-labels-from-", "Temporary repository to delete labels from") 72 | defer deleteProject(t, proj) 73 | 74 | addLabel(t, proj, "test-label", "#000000", "Test label description") 75 | 76 | if err := GitLabClient.Labels.DeleteWithRegex(proj.ID, ""); err != nil { 77 | t.Fatal(err) 78 | } 79 | if labels := getLabels(t, proj.ID); len(labels) > 0 { 80 | t.Fatalf("labels still exist after supposedly deleting them all: %v", labels) 81 | } 82 | } 83 | 84 | func TestLabels_CopyGlobalLabelsTo(t *testing.T) { 85 | before(t) 86 | 87 | proj := createProject(t, "temporary-copy-globals-to-", "Temporary repository to copy global labels to") 88 | defer deleteProject(t, proj) 89 | 90 | globalLabels := getLabels(t, proj.ID) 91 | 92 | if err := GitLabClient.Labels.DeleteWithRegex(proj.ID, ""); err != nil { 93 | t.Fatal(err) 94 | } 95 | 96 | if err := GitLabClient.Labels.CopyGlobalLabelsTo(proj.ID); err != nil { 97 | t.Fatal(err) 98 | } 99 | 100 | labels := getLabels(t, proj.ID) 101 | if len(labels) != len(globalLabels) { 102 | t.Fatalf("different number of labels\nglobalLabels: %v\nrepoLabels: %v", globalLabels, labels) 103 | } 104 | for i, label := range labels { 105 | global := globalLabels[i] 106 | if label.Name != global.Name || label.Color != global.Color { 107 | t.Fatalf("labels are different\nglobalLabels: %v\nrepoLabels: %v", globalLabels, labels) 108 | } 109 | } 110 | } 111 | 112 | // Helper functions: 113 | 114 | func getLabels(tb testing.TB, pid interface{}) []*gogitlab.Label { 115 | labels, _, err := GitLabClient.Labels.ListLabels(pid) 116 | if err != nil { 117 | // The failure happens at wherever we were called, not here 118 | _, file, line, ok := runtime.Caller(1) 119 | if !ok { 120 | tb.Fatalf("Unable to get caller") 121 | } 122 | tb.Fatalf("%s:%v %v", path.Base(file), line, err) 123 | } 124 | return labels 125 | } 126 | 127 | func addLabel(tb testing.TB, proj *gogitlab.Project, name, color, description string) *gogitlab.Label { 128 | l, _, err := GitLabClient.Labels.CreateLabel(proj.ID, &gogitlab.CreateLabelOptions{ 129 | Name: &name, 130 | Color: &color, 131 | Description: &description, 132 | }) 133 | if err != nil { 134 | // The failure happens at wherever we were called, not here 135 | _, file, line, ok := runtime.Caller(1) 136 | if !ok { 137 | tb.Fatalf("Unable to get caller") 138 | } 139 | tb.Fatalf("%s:%v %v", path.Base(file), line, err) 140 | } 141 | return l 142 | } 143 | 144 | func labelsExist(tb testing.TB, proj *gogitlab.Project, expected []*gogitlab.Label) { 145 | labels := getLabels(tb, proj.ID) 146 | for _, exp := range expected { 147 | found := false 148 | for _, l := range labels { 149 | e := *exp 150 | if exp.Color == "" { 151 | e.Color = l.Color 152 | } 153 | if exp.Description == "" { 154 | e.Description = l.Description 155 | } 156 | if exp.OpenIssuesCount == 0 { 157 | e.OpenIssuesCount = l.OpenIssuesCount 158 | } 159 | if exp.ClosedIssuesCount == 0 { 160 | e.ClosedIssuesCount = l.ClosedIssuesCount 161 | } 162 | if exp.OpenMergeRequestsCount == 0 { 163 | e.OpenMergeRequestsCount = l.OpenMergeRequestsCount 164 | } 165 | if reflect.DeepEqual(&e, l) { 166 | found = true 167 | break 168 | } 169 | } 170 | if !found { 171 | tb.Fatalf("label %v doesn't exist in %v", exp, labels) 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /gitlab/projects.go: -------------------------------------------------------------------------------- 1 | package gitlab 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | gogitlab "github.com/xanzy/go-gitlab" 8 | ) 9 | 10 | type Projects struct { 11 | *gogitlab.ProjectsService 12 | client *Client 13 | } 14 | 15 | type NotFound struct { 16 | s string 17 | } 18 | 19 | func (e *NotFound) Error() string { 20 | return e.s 21 | } 22 | 23 | // ProjectByPath searches and returns a project with the given path. 24 | // Both project and error can be nil, if no project was found. 25 | func (srv *Projects) ByPath(path string) (proj *gogitlab.Project, err error) { 26 | path = strings.TrimPrefix(strings.TrimSuffix(path, ".git"), "/") 27 | p := strings.Split(path, "/") 28 | findProject := func(p *gogitlab.Project) bool { 29 | if p.PathWithNamespace == path { 30 | proj = p 31 | return true 32 | } 33 | return false 34 | } 35 | err = srv.Search(p[1], &gogitlab.SearchProjectsOptions{}, findProject) 36 | if err == nil && proj == nil { 37 | err = &NotFound{fmt.Sprintf("repository with path '%s' was not found", path)} 38 | } 39 | return 40 | } 41 | 42 | func (srv *Projects) Search(query string, opts *gogitlab.SearchProjectsOptions, stop func(*gogitlab.Project) bool) error { 43 | projects, resp, err := srv.SearchProjects(query, opts) 44 | if err != nil { 45 | return err 46 | } 47 | for _, p := range projects { 48 | if stop(p) { 49 | return nil 50 | } 51 | } 52 | if resp.NextPage > 0 && resp.NextPage < resp.LastPage { 53 | opts.Page = resp.NextPage 54 | return srv.Search(query, opts, stop) 55 | } 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /gitlab/projects_test.go: -------------------------------------------------------------------------------- 1 | package gitlab 2 | 3 | import "testing" 4 | 5 | func TestProjects_ByPath(t *testing.T) { 6 | before(t) 7 | 8 | proj := createProject(t, "temporary-awesome-", "Temporary repository to check if exists") 9 | defer deleteProject(t, proj) 10 | 11 | type _test struct { 12 | path string 13 | } 14 | tests := []*_test{ 15 | &_test{proj.PathWithNamespace}, 16 | } 17 | for _, test := range tests { 18 | proj, err := GitLabClient.Projects.ByPath(test.path) 19 | if err != nil { 20 | t.Fatal(err) 21 | } 22 | if proj.PathWithNamespace != test.path { 23 | t.Errorf("expecting '%s', got '%s'\n", test.path, proj.PathWithNamespace) 24 | } 25 | } 26 | tests = []*_test{ 27 | &_test{"root/nonexistingrepo"}, 28 | } 29 | for _, test := range tests { 30 | proj, err := GitLabClient.Projects.ByPath(test.path) 31 | if _, ok := err.(*NotFound); !ok { 32 | t.Fatalf("expecting not found, got: %s", proj.PathWithNamespace) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /gitlab/utils.go: -------------------------------------------------------------------------------- 1 | package gitlab 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | ) 7 | 8 | // RandomString generates a random string of a specified length. 9 | func RandomString(strlen int) string { 10 | rand.Seed(time.Now().UTC().UnixNano()) 11 | const chars = "abcdefghijklmnopqrstuvwxyz0123456789" 12 | result := make([]byte, strlen) 13 | for i := 0; i < strlen; i++ { 14 | result[i] = chars[rand.Intn(len(chars))] 15 | } 16 | return string(result) 17 | } 18 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/clns/gitlab-cli/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | --------------------------------------------------------------------------------