├── .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 [](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 |
--------------------------------------------------------------------------------