├── .envrc ├── .github ├── CODEOWNERS ├── build └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .gitlint ├── .golangci.yml ├── .goreleaser.yml ├── .markdownlintrc ├── .pre-commit-config.yaml ├── .tool-versions ├── LICENSE ├── Makefile ├── README.md ├── api ├── api.go ├── api_test.go └── types.go ├── cmd ├── api │ └── api.go ├── completion │ ├── bash │ │ └── bash.go │ ├── completion.go │ └── zsh │ │ └── zsh.go ├── create │ ├── accesstoken │ │ └── accesstoken.go │ ├── create.go │ ├── project │ │ ├── project.go │ │ └── project_test.go │ └── vars │ │ └── vars.go ├── delete │ ├── delete.go │ └── variable │ │ └── variable.go ├── get │ ├── accesstokens │ │ └── accesstokens.go │ ├── get.go │ ├── issues │ │ └── issues.go │ ├── jobs │ │ └── jobs.go │ ├── logs │ │ └── logs.go │ ├── output │ │ └── output.go │ ├── pipelines │ │ └── pipelines.go │ ├── projects │ │ ├── projects.go │ │ └── projects_test.go │ └── vars │ │ └── vars.go ├── gitlab │ └── main.go ├── inspect │ ├── inspect.go │ ├── inspect_test.go │ ├── issue │ │ ├── issue.go │ │ └── issue_test.go │ ├── pipeline │ │ └── pipeline.go │ └── project │ │ └── projet.go ├── login │ ├── login.go │ └── term.go ├── revoke │ ├── accesstoken │ │ └── accesstoken.go │ └── revoke.go ├── root.go ├── status │ └── status.go ├── update │ ├── releases_test.atom │ ├── releases_test_major_upgrade.atom │ ├── releases_test_major_upgrade_pre.atom │ ├── update.go │ └── update_test.go └── version │ └── version.go ├── config ├── cache.go ├── cache_test.go └── config.go ├── go.mod ├── go.sum ├── install.sh ├── mock └── mock.go ├── support └── pre-push ├── table ├── table.go └── table_test.go └── versions └── versions.go /.envrc: -------------------------------------------------------------------------------- 1 | use asdf 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @makkes 2 | -------------------------------------------------------------------------------- /.github/build: -------------------------------------------------------------------------------- 1 | make release 2 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - 'v*' 5 | name: Release 6 | jobs: 7 | create_release: 8 | name: Create GitHub release 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@v2 13 | with: 14 | fetch-depth: 0 15 | - name: Install asdf and tools 16 | uses: asdf-vm/actions/install@v1 17 | - name: create release 18 | run: make release 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: [push] 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - uses: asdf-vm/actions/install@v1 9 | - name: Lint code 10 | run: make lint 11 | - name: pre-commit 12 | uses: pre-commit/action@v2.0.3 13 | with: 14 | extra_args: --all-files 15 | env: 16 | SKIP: no-commit-to-branch,golangci-lint 17 | - name: Run unit tests 18 | run: make test 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /gitlab 2 | /gitlab_v* 3 | cover.out 4 | /build 5 | 6 | # output of goreleaser 7 | /dist/ 8 | -------------------------------------------------------------------------------- /.gitlint: -------------------------------------------------------------------------------- 1 | # `gitlint` is used to validate commit messages via `pre-commit` hook, enabled 2 | # via `pre-commit install -t commit-msg`. 3 | # 4 | # The format of this file is detailed at https://jorisroovers.com/gitlint/#configuration. 5 | 6 | [general] 7 | # Validate the commit title conforms to conventional commits (https://www.conventionalcommits.org/en/v1.0.0/). 8 | contrib=contrib-title-conventional-commits 9 | # Do not require a body in the git commit. 10 | ignore=body-is-missing 11 | 12 | [body-max-line-length] 13 | line-length=120 14 | 15 | [ignore-body-lines] 16 | # Ignore dependabot long lines. 17 | regex=^Bumps .+ from .+ to .+\.$ 18 | 19 | [contrib-title-conventional-commits] 20 | # Specify allowed commit types. For details see: https://www.conventionalcommits.org/ 21 | types = fix,feat,docs,style,refactor,perf,test,revert,ci,build 22 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | disable-all: true 3 | enable: 4 | - deadcode 5 | - depguard 6 | - errcheck 7 | - gochecknoinits 8 | - gci 9 | - goconst 10 | - gocritic 11 | - gocyclo 12 | - godot 13 | - gofmt 14 | - gosec 15 | - gosimple 16 | - govet 17 | - ineffassign 18 | - lll 19 | - misspell 20 | - nolintlint 21 | - prealloc 22 | - staticcheck 23 | - structcheck 24 | - stylecheck 25 | - typecheck 26 | - unconvert 27 | - unparam 28 | - unused 29 | - varcheck 30 | - whitespace 31 | 32 | linters-settings: 33 | lll: 34 | line-length: 150 35 | gci: 36 | local-prefixes: github.com/makkes/gitlab-cli 37 | 38 | issues: 39 | exclude-rules: 40 | # ignore errcheck for code under a /test folder 41 | - path: "test/*" 42 | linters: 43 | - errcheck 44 | # ignore errcheck for flags.Parse (it is expected that we flag.ExitOnError) 45 | # ignore response.WriteError as it always returns the err it was passed 46 | - source: "flags.Parse|response.WriteError" 47 | linters: 48 | - errcheck 49 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: gitlab 2 | 3 | changelog: 4 | use: github 5 | groups: 6 | - title: Features 7 | regexp: "^.*feat[(\\w)]*:+.*$" 8 | order: 0 9 | - title: 'Bug fixes' 10 | regexp: "^.*fix[(\\w)]*:+.*$" 11 | order: 1 12 | filters: 13 | exclude: 14 | - '^docs:' 15 | - '^chore:' 16 | - '^build:' 17 | 18 | release: 19 | footer: | 20 | ### Summary 21 | **Full Changelog**: https://github.com/makkes/gitlab-cli/compare/{{ .PreviousTag }}...{{ .Tag }} 22 | 23 | builds: 24 | - id: gitlab 25 | main: ./cmd/gitlab 26 | env: 27 | - CGO_ENABLED=0 28 | flags: 29 | - -trimpath 30 | ldflags: 31 | - -s 32 | - -w 33 | - -X 'github.com/makkes/gitlab-cli/config.Version={{ .Version }}' 34 | goos: 35 | - linux 36 | - windows 37 | - darwin 38 | goarch: 39 | - amd64 40 | - arm64 41 | mod_timestamp: '{{ .CommitTimestamp }}' 42 | universal_binaries: 43 | - replace: true 44 | id: gitlab 45 | archives: 46 | - name_template: '{{ .ProjectName }}_v{{trimprefix .Version "v"}}_{{ .Os }}_{{ .Arch }}' 47 | # This is a hack documented in https://github.com/goreleaser/goreleaser/blob/df0216d5855e9283d2106fb5acdb0e7b528a56e8/www/docs/customization/archive.md#packaging-only-the-binaries 48 | files: 49 | - none* 50 | format_overrides: 51 | - goos: windows 52 | format: zip 53 | checksum: 54 | name_template: 'checksums.txt' 55 | snapshot: 56 | name_template: "{{ incminor .Tag }}-dev" 57 | -------------------------------------------------------------------------------- /.markdownlintrc: -------------------------------------------------------------------------------- 1 | { 2 | "default": true, 3 | "MD003": { "style": "atx" }, 4 | "MD004": { "style": "dash" }, 5 | "MD007": { "indent": 4 }, 6 | "MD013": false, 7 | "MD030": { "ul_multi": 3, "ol_multi": 2 }, 8 | "MD035": { "style": "---" } 9 | } 10 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: go-mod-tidy 5 | name: go mod tidy 6 | entry: make mod-tidy 7 | files: "(.*\\.go|go.mod|go.sum)$" 8 | language: system 9 | stages: [commit] 10 | pass_filenames: false 11 | - id: golangci-lint 12 | name: golangci-lint 13 | entry: make lint 14 | language: system 15 | files: "(.*\\.go|go.mod|go.sum)$" 16 | pass_filenames: false 17 | - repo: https://github.com/pre-commit/pre-commit-hooks 18 | rev: v4.1.0 19 | hooks: 20 | - id: trailing-whitespace 21 | stages: [commit] 22 | exclude: "^(cmd\/get\/projects\/projects_test|table\/table_test)\\.go$" 23 | - id: check-yaml 24 | args: ["-m", "--unsafe"] 25 | stages: [commit] 26 | - id: mixed-line-ending 27 | args: ["-f", "lf"] 28 | exclude: \.bat$ 29 | stages: [commit] 30 | - id: no-commit-to-branch 31 | stages: [commit] 32 | - id: check-added-large-files 33 | stages: [commit] 34 | - id: check-case-conflict 35 | stages: [commit] 36 | - id: check-merge-conflict 37 | stages: [commit] 38 | - id: check-executables-have-shebangs 39 | stages: [commit] 40 | exclude: skopeo/static/.+$ 41 | - id: check-symlinks 42 | stages: [commit] 43 | - id: end-of-file-fixer 44 | stages: [commit] 45 | - repo: https://github.com/jorisroovers/gitlint 46 | rev: v0.17.0 47 | hooks: 48 | - id: gitlint 49 | stages: [commit-msg] 50 | - repo: https://github.com/jumanjihouse/pre-commit-hooks 51 | rev: 2.1.5 52 | hooks: 53 | - id: shfmt 54 | stages: [commit] 55 | args: ["-s", "-i", "2"] 56 | - id: script-must-have-extension 57 | stages: [commit] 58 | - repo: https://github.com/shellcheck-py/shellcheck-py 59 | rev: v0.8.0.3 60 | hooks: 61 | - id: shellcheck 62 | stages: [commit] 63 | args: ["-e", "SC2211"] 64 | - repo: https://github.com/igorshubovych/markdownlint-cli 65 | rev: v0.30.0 66 | hooks: 67 | - id: markdownlint 68 | stages: [commit] 69 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | golangci-lint 1.44.2 2 | golang 1.17.7 3 | goreleaser 1.5.0 4 | pre-commit 2.17.0 5 | shfmt 3.4.3 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Max Jonas Werner 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 15 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 17 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 18 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 19 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 20 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 21 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 22 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := build-snapshot 2 | 3 | .PHONY: lint 4 | lint: 5 | golangci-lint run 6 | .PHONY: test 7 | test: 8 | go test ./... 9 | 10 | GORELEASER_PARALLELISM ?= $(shell nproc --ignore=1) 11 | GORELEASER_DEBUG ?= false 12 | 13 | export GORELEASER_CURRENT_TAG=$(GIT_TAG) 14 | 15 | ifneq ($(shell git status --porcelain 2>/dev/null; echo $$?), 0) 16 | export GIT_TREE_STATE := dirty 17 | else 18 | export GIT_TREE_STATE := 19 | endif 20 | 21 | .PHONY: build-snapshot 22 | build-snapshot: ## Builds a snapshot with goreleaser 23 | build-snapshot: 24 | goreleaser --debug=$(GORELEASER_DEBUG) \ 25 | build \ 26 | --snapshot \ 27 | --rm-dist \ 28 | --parallelism=$(GORELEASER_PARALLELISM) \ 29 | --single-target \ 30 | --skip-post-hooks 31 | 32 | .PHONY: release 33 | release: ## Builds a release with goreleaser 34 | release: 35 | goreleaser --debug=$(GORELEASER_DEBUG) \ 36 | release \ 37 | --rm-dist \ 38 | --parallelism=$(GORELEASER_PARALLELISM) 39 | 40 | .PHONY: release-snapshot 41 | release-snapshot: ## Builds a snapshot release with goreleaser 42 | release-snapshot: 43 | goreleaser --debug=$(GORELEASER_DEBUG) \ 44 | release \ 45 | --snapshot \ 46 | --skip-publish \ 47 | --rm-dist \ 48 | --parallelism=$(GORELEASER_PARALLELISM) 49 | 50 | .PHONY: mod-tidy 51 | mod-tidy: ## Run go mod tidy 52 | go mod tidy -v -compat=1.17 53 | go mod verify 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This tool is deprecated. Please use the [official GitLab CLI](https://gitlab.com/gitlab-org/cli). 2 | -------------------------------------------------------------------------------- /api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io/ioutil" 9 | "net/http" 10 | "net/url" 11 | "strconv" 12 | "strings" 13 | "time" 14 | 15 | "github.com/makkes/gitlab-cli/config" 16 | ) 17 | 18 | var ErrNotLoggedIn = errors.New("you are not logged in") 19 | 20 | type Client interface { 21 | Get(path string) ([]byte, int, error) 22 | Post(path string, body interface{}) ([]byte, int, error) 23 | Delete(path string) (int, error) 24 | FindProject(nameOrID string) (*Project, error) 25 | FindProjectDetails(nameOrID string) ([]byte, error) 26 | Login(token, url string) (string, error) 27 | GetPipelineDetails(projectID, pipelineID string) ([]byte, error) 28 | GetAccessTokens(projectID string) ([]ProjectAccessToken, error) 29 | CreateAccessToken(projectID int, name string, expires time.Time, scopes []string) (ProjectAccessToken, error) 30 | } 31 | 32 | var _ Client = &HTTPClient{} 33 | 34 | type HTTPClient struct { 35 | basePath string 36 | config config.Config 37 | client http.Client 38 | } 39 | 40 | func NewAPIClient(cfg config.Config) *HTTPClient { 41 | client := http.Client{} 42 | return &HTTPClient{ 43 | basePath: "/api/v4", 44 | config: cfg, 45 | client: client, 46 | } 47 | } 48 | 49 | func (c HTTPClient) parse(input string) string { 50 | return strings.ReplaceAll(input, "${user}", c.config.Get(config.User)) 51 | } 52 | 53 | func (c HTTPClient) CreateAccessToken(pid int, name string, exp time.Time, scopes []string) (ProjectAccessToken, error) { 54 | pat := ProjectAccessToken{ 55 | Name: name, 56 | ExpiresAt: exp, 57 | Scopes: scopes, 58 | } 59 | 60 | res, _, err := c.Post(fmt.Sprintf("/projects/%s/access_tokens", url.PathEscape(strconv.Itoa(pid))), pat) 61 | if err != nil { 62 | return ProjectAccessToken{}, fmt.Errorf("API request failed: %w", err) 63 | } 64 | 65 | var dec map[string]interface{} 66 | if err := json.Unmarshal(res, &dec); err != nil { 67 | return ProjectAccessToken{}, fmt.Errorf("failed unmarshalling response: %w", err) 68 | } 69 | pat, err = decodePAT(dec) 70 | if err != nil { 71 | return ProjectAccessToken{}, fmt.Errorf("failed decoding response: %w", err) 72 | } 73 | 74 | return pat, nil 75 | } 76 | 77 | func (c HTTPClient) GetAccessTokens(pid string) ([]ProjectAccessToken, error) { 78 | resp, _, err := c.Get(fmt.Sprintf("/projects/%s/access_tokens", url.PathEscape(pid))) 79 | if err != nil { 80 | return nil, err 81 | } 82 | 83 | var decObj []map[string]interface{} 84 | err = json.Unmarshal(resp, &decObj) 85 | if err != nil { 86 | return nil, fmt.Errorf("failed unmarshalling response: %w", err) 87 | } 88 | 89 | atl := make([]ProjectAccessToken, len(decObj)) 90 | for idx, obj := range decObj { 91 | pat, err := decodePAT(obj) 92 | if err != nil { 93 | return nil, fmt.Errorf("failed decoding token: %w", err) 94 | } 95 | atl[idx] = pat 96 | } 97 | 98 | return atl, nil 99 | } 100 | 101 | func decodePAT(obj map[string]interface{}) (ProjectAccessToken, error) { 102 | name, ok := obj["name"].(string) 103 | if !ok { 104 | return ProjectAccessToken{}, fmt.Errorf("failed decoding 'name' field: %v", obj["name"]) 105 | } 106 | 107 | id, ok := obj["id"].(float64) 108 | if !ok { 109 | return ProjectAccessToken{}, fmt.Errorf("failed decoding 'id' field: %v", obj["id"]) 110 | } 111 | 112 | expires, ok := obj["expires_at"].(string) 113 | if !ok { 114 | return ProjectAccessToken{}, fmt.Errorf("failed decoding 'expires' field: %v", obj["expires"]) 115 | } 116 | et, err := time.Parse("2006-01-02", expires) 117 | if err != nil { 118 | return ProjectAccessToken{}, fmt.Errorf("failed parsing 'expires' field: %w", err) 119 | } 120 | 121 | scopesIf, ok := obj["scopes"].([]interface{}) 122 | if !ok { 123 | return ProjectAccessToken{}, fmt.Errorf("failed decoding 'scopes' field: %v", obj["scopes"]) 124 | } 125 | 126 | scopes := make([]string, len(scopesIf)) 127 | for idx, scopeIf := range scopesIf { 128 | scope, ok := scopeIf.(string) 129 | if !ok { 130 | return ProjectAccessToken{}, fmt.Errorf("failed decoding scope: %v", scopeIf) 131 | } 132 | scopes[idx] = scope 133 | } 134 | 135 | var token string 136 | if obj["token"] != nil { 137 | var ok bool 138 | token, ok = obj["token"].(string) 139 | if !ok { 140 | return ProjectAccessToken{}, fmt.Errorf("failed decoding 'token' field: %v", obj["token"]) 141 | } 142 | } 143 | 144 | return ProjectAccessToken{ 145 | ID: int(id), 146 | Name: name, 147 | ExpiresAt: et, 148 | Scopes: scopes, 149 | Token: token, 150 | }, nil 151 | } 152 | 153 | func (c *HTTPClient) Login(token, url string) (string, error) { 154 | c.config.Set(config.Token, token) 155 | c.config.Set(config.URL, url) 156 | res, _, err := c.Get("/user") 157 | if err != nil { 158 | return "", err 159 | } 160 | var user struct { 161 | Username string 162 | } 163 | err = json.Unmarshal(res, &user) 164 | if err != nil { 165 | return "", err 166 | } 167 | c.config.Set(config.User, user.Username) 168 | c.config.Cache().Flush() 169 | return user.Username, nil 170 | } 171 | 172 | func (c HTTPClient) GetPipelineDetails(projectID, pipelineID string) ([]byte, error) { 173 | resp, _, err := c.Get(fmt.Sprintf("/projects/%s/pipelines/%s", url.PathEscape(projectID), url.PathEscape(pipelineID))) 174 | if err != nil { 175 | return nil, err 176 | } 177 | return resp, nil 178 | } 179 | 180 | // FindProjectDetails searches for a project by its ID or its name, 181 | // with the ID having precedence over the name and returns the 182 | // raw JSON object as byte array. 183 | func (c HTTPClient) FindProjectDetails(nameOrID string) ([]byte, error) { 184 | // first try to get the project by its cached ID 185 | if cachedID := c.config.Cache().Get("projects", nameOrID); cachedID != "" { 186 | resp, _, err := c.Get("/projects/" + url.PathEscape(cachedID)) 187 | if err == nil { 188 | return resp, nil 189 | } 190 | } 191 | 192 | // then try to find the project by its ID 193 | resp, _, err := c.Get("/projects/" + url.PathEscape(nameOrID)) 194 | if err == nil { 195 | return resp, nil 196 | } 197 | 198 | // now try to find the project by name as a last resort 199 | resp, _, err = c.Get("/users/${user}/projects/?search=" + url.QueryEscape(nameOrID)) 200 | if err != nil { 201 | return nil, err 202 | } 203 | projects := make([]map[string]interface{}, 0) 204 | err = json.Unmarshal(resp, &projects) 205 | if err != nil { 206 | return nil, err 207 | } 208 | if len(projects) == 0 { 209 | return nil, fmt.Errorf("Project '%s' not found", nameOrID) 210 | } 211 | c.config.Cache().Put("projects", nameOrID, strconv.Itoa(int((projects[0]["id"].(float64))))) 212 | c.config.Write() 213 | res, err := json.Marshal(projects[0]) 214 | if err != nil { 215 | return nil, err 216 | } 217 | return res, nil 218 | } 219 | 220 | // FindProject searches for a project by its ID or its name, 221 | // with the ID having precedence over the name. 222 | func (c HTTPClient) FindProject(nameOrID string) (*Project, error) { 223 | projectBytes, err := c.FindProjectDetails(nameOrID) 224 | if err != nil { 225 | return nil, err 226 | } 227 | var project Project 228 | err = json.Unmarshal(projectBytes, &project) 229 | if err != nil { 230 | return nil, err 231 | } 232 | return &project, nil 233 | } 234 | 235 | func (c HTTPClient) isLoggedIn() bool { 236 | return c.config != nil && c.config.Get(config.URL) != "" && c.config.Get(config.Token) != "" 237 | } 238 | 239 | func (c HTTPClient) Get(path string) ([]byte, int, error) { 240 | if !c.isLoggedIn() { 241 | return nil, 0, ErrNotLoggedIn 242 | } 243 | req, err := http.NewRequest("GET", c.config.Get(config.URL)+c.basePath+c.parse(path), nil) 244 | if err != nil { 245 | return nil, 0, err 246 | } 247 | req.Header.Add("Private-Token", c.config.Get(config.Token)) 248 | resp, err := c.client.Do(req) 249 | if err != nil { 250 | return nil, 0, err 251 | } 252 | if resp.StatusCode != http.StatusOK { 253 | return nil, resp.StatusCode, fmt.Errorf("%s", resp.Status) 254 | } 255 | body, err := ioutil.ReadAll(resp.Body) 256 | if err != nil { 257 | return nil, resp.StatusCode, err 258 | } 259 | return body, 0, nil 260 | } 261 | 262 | func (c HTTPClient) Post(path string, reqBody interface{}) ([]byte, int, error) { 263 | if !c.isLoggedIn() { 264 | return nil, 0, ErrNotLoggedIn 265 | } 266 | var bodyBuf bytes.Buffer 267 | if err := json.NewEncoder(&bodyBuf).Encode(reqBody); err != nil { 268 | return nil, 0, fmt.Errorf("could not encode JSON body: %w", err) 269 | } 270 | req, err := http.NewRequest("POST", c.config.Get(config.URL)+c.basePath+c.parse(path), &bodyBuf) 271 | if err != nil { 272 | return nil, 0, err 273 | } 274 | req.Header.Set("Private-Token", c.config.Get(config.Token)) 275 | req.Header.Set("Content-Type", "application/json") 276 | resp, err := c.client.Do(req) 277 | if err != nil { 278 | return nil, 0, err 279 | } 280 | if resp.StatusCode < 200 || resp.StatusCode > 299 { 281 | return nil, resp.StatusCode, fmt.Errorf("%s", resp.Status) 282 | } 283 | body, err := ioutil.ReadAll(resp.Body) 284 | if err != nil { 285 | return nil, 0, err 286 | } 287 | return body, 0, nil 288 | } 289 | 290 | func (c HTTPClient) Delete(path string) (int, error) { 291 | if !c.isLoggedIn() { 292 | return 0, ErrNotLoggedIn 293 | } 294 | req, err := http.NewRequest("DELETE", c.config.Get(config.URL)+c.basePath+c.parse(path), nil) 295 | if err != nil { 296 | return 0, err 297 | } 298 | req.Header.Add("Private-Token", c.config.Get(config.Token)) 299 | resp, err := c.client.Do(req) 300 | if err != nil { 301 | return 0, err 302 | } 303 | 304 | if resp.StatusCode < 200 || resp.StatusCode > 299 { 305 | return resp.StatusCode, fmt.Errorf("%s", resp.Status) 306 | } 307 | return resp.StatusCode, nil 308 | } 309 | -------------------------------------------------------------------------------- /api/api_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestPipelineDetailsDurationRunning(t *testing.T) { 9 | var zeroTime time.Time 10 | 11 | tests := []struct { 12 | now time.Time 13 | started time.Time 14 | updated time.Time 15 | finished time.Time 16 | recordedDuration *int 17 | status string 18 | out string 19 | }{ 20 | { 21 | time.Unix(1549449133, 0), 22 | time.Unix(1549448118, 0), 23 | time.Unix(1549449125, 0), 24 | time.Unix(1549449129, 0), 25 | nil, 26 | "running", 27 | "8s", 28 | }, 29 | { 30 | time.Unix(1549449150, 0), 31 | time.Unix(1549449125, 0), 32 | zeroTime, 33 | zeroTime, 34 | nil, 35 | "running", 36 | "25s", 37 | }, 38 | { 39 | time.Unix(1549449150, 0), 40 | time.Unix(1549449125, 0), 41 | zeroTime, 42 | zeroTime, 43 | nil, 44 | "failed", 45 | "-", 46 | }, 47 | { 48 | zeroTime, 49 | zeroTime, 50 | zeroTime, 51 | zeroTime, 52 | func() *int { i := int(2283); return &i }(), 53 | "success", 54 | "38m3s", 55 | }, 56 | } 57 | 58 | for _, tt := range tests { 59 | pd := PipelineDetails{ 60 | StartedAt: tt.started, 61 | UpdatedAt: tt.updated, 62 | FinishedAt: tt.finished, 63 | RecordedDuration: tt.recordedDuration, 64 | Status: tt.status, 65 | } 66 | 67 | res := pd.Duration(tt.now) 68 | if res != tt.out { 69 | t.Errorf("Unexpected duration: %s", res) 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /api/types.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import "time" 4 | 5 | type Project struct { 6 | ID int `json:"id"` 7 | Name string `json:"name"` 8 | SSHGitURL string `json:"ssh_url_to_repo"` 9 | HTTPGitURL string `json:"http_url_to_repo"` 10 | URL string `json:"web_url"` 11 | LastActivityAt string `json:"last_activity_at"` 12 | } 13 | 14 | type ProjectAccessToken struct { 15 | ID int `json:"id"` 16 | Name string `json:"name"` 17 | ExpiresAt time.Time `json:"expires_at"` 18 | Scopes []string `json:"scopes"` 19 | Token string `json:"token,omitempty"` 20 | } 21 | 22 | type Issue struct { 23 | ProjectID int `json:"project_id"` 24 | ID int `json:"iid"` 25 | Title string `json:"title"` 26 | URL string `json:"web_url"` 27 | State string `json:"state"` 28 | } 29 | 30 | type Pipeline struct { 31 | ID int 32 | Status string 33 | } 34 | 35 | type PipelineDetails struct { 36 | ProjectID int 37 | ID int 38 | Status string 39 | URL string `json:"web_url"` 40 | RecordedDuration *int `json:"duration"` 41 | StartedAt time.Time `json:"started_at"` 42 | UpdatedAt time.Time `json:"updated_at"` 43 | FinishedAt time.Time `json:"finished_at"` 44 | } 45 | 46 | type Var struct { 47 | Key string 48 | Value string 49 | Protected bool 50 | EnvironmentScope string `json:"environment_scope"` 51 | } 52 | 53 | type Job struct { 54 | ID int `json:"id"` 55 | ProjectID int `json:"project_id"` 56 | Stage string `json:"stage"` 57 | Status string `json:"status"` 58 | } 59 | 60 | type Jobs []Job 61 | 62 | func (pd PipelineDetails) Duration(now time.Time) string { 63 | if pd.Status == "running" { 64 | started := pd.StartedAt 65 | if !pd.FinishedAt.IsZero() { 66 | started = pd.UpdatedAt 67 | } 68 | return now.Sub(started).Truncate(time.Second).String() 69 | } 70 | if pd.RecordedDuration == nil { 71 | return "-" 72 | } 73 | return time.Duration(int(time.Second) * *pd.RecordedDuration).String() 74 | } 75 | 76 | type Pipelines []Pipeline 77 | 78 | func (p Pipelines) Filter(cb func(int, Pipeline) bool) Pipelines { 79 | res := make(Pipelines, 0) 80 | for idx, pipeline := range p { 81 | if cb(idx, pipeline) { 82 | res = append(res, pipeline) 83 | } 84 | } 85 | return res 86 | } 87 | -------------------------------------------------------------------------------- /cmd/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/makkes/gitlab-cli/api" 9 | "github.com/makkes/gitlab-cli/config" 10 | ) 11 | 12 | func NewCommand(client api.Client, cfg config.Config) *cobra.Command { 13 | cmd := &cobra.Command{ 14 | Use: "api PATH", 15 | Short: "Makes an authenticated HTTP request to the GitLab API and prints the response.", 16 | Args: cobra.ExactArgs(1), 17 | RunE: func(cmd *cobra.Command, args []string) error { 18 | rawRes, sc, err := client.Get(args[0]) 19 | if err != nil { 20 | return fmt.Errorf("error making API request '%s' (got status code %d): %w", args[0], sc, err) 21 | } 22 | fmt.Printf("%s\n", rawRes) 23 | return nil 24 | }, 25 | } 26 | 27 | return cmd 28 | } 29 | -------------------------------------------------------------------------------- /cmd/completion/bash/bash.go: -------------------------------------------------------------------------------- 1 | package bash 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | func NewCommand(rootCmd *cobra.Command) *cobra.Command { 10 | return &cobra.Command{ 11 | Use: "bash", 12 | Short: "generate bash completion", 13 | RunE: func(cmd *cobra.Command, args []string) error { 14 | return rootCmd.GenBashCompletion(os.Stdout) 15 | }, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /cmd/completion/completion.go: -------------------------------------------------------------------------------- 1 | package completion 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/makkes/gitlab-cli/cmd/completion/bash" 7 | "github.com/makkes/gitlab-cli/cmd/completion/zsh" 8 | ) 9 | 10 | func NewCommand(rootCmd *cobra.Command) *cobra.Command { 11 | cmd := &cobra.Command{ 12 | Use: "completion", 13 | Short: "Generate shell completion scripts", 14 | Long: `Generate shell completion scripts 15 | 16 | To load completions in the current shell run 17 | 18 | source <(gitlab completion SHELL) 19 | 20 | To configure your bash shell to load completions for each session add the 21 | following line to your ~/.bashrc or ~/.profile: 22 | 23 | source <(gitlab completion bash) 24 | 25 | If you use the zsh shell, run this command to permanently load completions: 26 | 27 | gitlab completion zsh |sudo tee "${fpath[1]}/_gitlab" 28 | `, 29 | } 30 | 31 | cmd.AddCommand(bash.NewCommand(rootCmd)) 32 | cmd.AddCommand(zsh.NewCommand(rootCmd)) 33 | 34 | return cmd 35 | } 36 | -------------------------------------------------------------------------------- /cmd/completion/zsh/zsh.go: -------------------------------------------------------------------------------- 1 | package zsh 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | func NewCommand(rootCmd *cobra.Command) *cobra.Command { 10 | return &cobra.Command{ 11 | Use: "zsh", 12 | Short: "generate zsh completion", 13 | RunE: func(cmd *cobra.Command, args []string) error { 14 | return rootCmd.GenZshCompletion(os.Stdout) 15 | }, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /cmd/create/accesstoken/accesstoken.go: -------------------------------------------------------------------------------- 1 | package accesstoken 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "os" 7 | "time" 8 | 9 | "github.com/spf13/cobra" 10 | 11 | "github.com/makkes/gitlab-cli/api" 12 | ) 13 | 14 | var letters = []rune("0123456789abcdefghijklmnopqrstuvwxyz") 15 | 16 | func randSeq(n int) string { 17 | b := make([]rune, n) 18 | for i := range b { 19 | b[i] = letters[rand.Intn(len(letters))] // nolint:gosec 20 | } 21 | return string(b) 22 | } 23 | 24 | func NewCommand(client api.Client) *cobra.Command { 25 | var projectFlag string 26 | var scopesFlag []string 27 | var nameFlag string 28 | 29 | cmd := &cobra.Command{ 30 | Use: "access-token", 31 | Short: "Create a project access token", 32 | RunE: func(cmd *cobra.Command, args []string) error { 33 | if projectFlag == "" { 34 | fmt.Printf("missing project name/ID\n\n") 35 | return cmd.Usage() 36 | } 37 | 38 | if nameFlag == "" { 39 | nameFlag = randSeq(16) 40 | } 41 | 42 | p, err := client.FindProject(projectFlag) 43 | if err != nil { 44 | return fmt.Errorf("failed finding project: %w", err) 45 | } 46 | 47 | pat, err := client.CreateAccessToken(p.ID, nameFlag, time.Now().Add(24*time.Hour), scopesFlag) 48 | if err != nil { 49 | return fmt.Errorf("error creating access token: %w", err) 50 | } 51 | 52 | fmt.Fprintf(os.Stderr, "project access token %q created in %q\n", pat.Name, p.Name) 53 | fmt.Fprintf(os.Stdout, "%s\n", pat.Token) 54 | 55 | return nil 56 | }, 57 | } 58 | 59 | cmd.Flags().StringVarP(&projectFlag, "project", "p", "", "project for which to list the tokens") 60 | cmd.Flags().StringVarP(&nameFlag, "name", "n", "", "name of the new token") 61 | cmd.Flags().StringSliceVarP(&scopesFlag, "scopes", "s", []string{"api"}, "scopes to apply to the new access token") 62 | 63 | return cmd 64 | } 65 | -------------------------------------------------------------------------------- /cmd/create/create.go: -------------------------------------------------------------------------------- 1 | package create 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/makkes/gitlab-cli/api" 7 | "github.com/makkes/gitlab-cli/cmd/create/accesstoken" 8 | createproj "github.com/makkes/gitlab-cli/cmd/create/project" 9 | "github.com/makkes/gitlab-cli/cmd/create/vars" 10 | "github.com/makkes/gitlab-cli/config" 11 | ) 12 | 13 | func NewCommand(client api.Client, cfg config.Config) *cobra.Command { 14 | var project *string 15 | cmd := &cobra.Command{ 16 | Use: "create", 17 | Short: "Create a resource such as a project or a variable", 18 | } 19 | 20 | project = cmd.PersistentFlags().StringP("project", "p", "", "If present, the project scope for this CLI request") 21 | 22 | cmd.AddCommand(createproj.NewCommand(client)) 23 | cmd.AddCommand(vars.NewCommand(client, project)) 24 | cmd.AddCommand(accesstoken.NewCommand(client)) 25 | 26 | return cmd 27 | } 28 | -------------------------------------------------------------------------------- /cmd/create/project/project.go: -------------------------------------------------------------------------------- 1 | package project 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/url" 8 | "os" 9 | 10 | "github.com/spf13/cobra" 11 | 12 | "github.com/makkes/gitlab-cli/api" 13 | ) 14 | 15 | func createCommand(client api.Client, args []string, out io.Writer) error { 16 | res, _, err := client.Post("/projects", map[string]interface{}{ 17 | "name": url.QueryEscape(args[0]), 18 | }) 19 | if err != nil { 20 | return fmt.Errorf("cannot create project: %s", err) 21 | } 22 | createdProject := make(map[string]interface{}) 23 | err = json.Unmarshal(res, &createdProject) 24 | if err != nil { 25 | return fmt.Errorf("unexpected result from GitLab API: %s", err) 26 | } 27 | fmt.Fprintf(out, `Project '%s' created 28 | Clone via SSH: %s 29 | Clone via HTTP: %s 30 | `, 31 | createdProject["name"], createdProject["ssh_url_to_repo"], createdProject["http_url_to_repo"]) 32 | 33 | return nil 34 | } 35 | 36 | func NewCommand(client api.Client) *cobra.Command { 37 | return &cobra.Command{ 38 | Use: "project NAME", 39 | Short: "Create a new project", 40 | Args: cobra.ExactArgs(1), 41 | RunE: func(cmd *cobra.Command, args []string) error { 42 | return createCommand(client, args, os.Stdout) 43 | }, 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /cmd/create/project/project_test.go: -------------------------------------------------------------------------------- 1 | package project 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/makkes/gitlab-cli/mock" 9 | ) 10 | 11 | func TestClientError(t *testing.T) { 12 | var out strings.Builder 13 | client := mock.Client{ 14 | Err: fmt.Errorf("Some client error"), 15 | } 16 | err := createCommand(client, []string{"new project"}, &out) 17 | if err == nil { 18 | t.Error("Expected a non-nil error") 19 | } 20 | if err.Error() != "cannot create project: Some client error" { 21 | t.Errorf("Unexpected error message '%s'", err) 22 | } 23 | if out.String() != "" { 24 | t.Errorf("Expected output to be empty but it is '%s'", out.String()) 25 | } 26 | } 27 | 28 | func TestUnknownResultFromGitLab(t *testing.T) { 29 | var out strings.Builder 30 | client := mock.Client{ 31 | Res: []byte("This is not JSON"), 32 | } 33 | err := createCommand(client, []string{"new project"}, &out) 34 | if err == nil { 35 | t.Error("Expected a non-nil error") 36 | } 37 | if err.Error() != "unexpected result from GitLab API: invalid character 'T' looking for beginning of value" { 38 | t.Errorf("Unexpected error message '%s'", err) 39 | } 40 | if out.String() != "" { 41 | t.Errorf("Expected output to be empty but it is '%s'", out.String()) 42 | } 43 | } 44 | 45 | func TestHappyPath(t *testing.T) { 46 | var out strings.Builder 47 | client := mock.Client{ 48 | Res: []byte(`{ 49 | "name": "new project", 50 | "ssh_url_to_repo": "SSH clone URL", 51 | "http_url_to_repo": "HTTP clone URL"}`), 52 | } 53 | err := createCommand(client, []string{"new project"}, &out) 54 | if err != nil { 55 | t.Errorf("Expected no error but got '%s'", err) 56 | } 57 | if out.String() != "Project 'new project' created\nClone via SSH: SSH clone URL\nClone via HTTP: HTTP clone URL\n" { 58 | t.Errorf("Unexpected output '%s'", out.String()) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /cmd/create/vars/vars.go: -------------------------------------------------------------------------------- 1 | package vars 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strconv" 7 | 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/makkes/gitlab-cli/api" 11 | ) 12 | 13 | func NewCommand(client api.Client, project *string) *cobra.Command { 14 | return &cobra.Command{ 15 | Use: "var KEY VALUE [ENVIRONMENT_SCOPE]", 16 | Short: "Create a project-level variable", 17 | Long: "Create a project-level variable. The KEY may only contain the characters A-Z, a-z, 0-9, and _ and " + 18 | "must be no longer than 255 characters.", 19 | Args: cobra.RangeArgs(2, 3), 20 | RunE: func(cmd *cobra.Command, args []string) error { 21 | if project == nil || *project == "" { 22 | return fmt.Errorf("please provide a project scope") 23 | } 24 | project, err := client.FindProject(*project) 25 | if err != nil { 26 | return fmt.Errorf("cannot create variable: %s", err) 27 | } 28 | 29 | body := map[string]interface{}{ 30 | "key": url.QueryEscape(args[0]), 31 | "value": url.QueryEscape(args[1]), 32 | } 33 | if len(args) == 3 { 34 | body["environment_scope"] = url.QueryEscape(args[2]) 35 | } 36 | _, _, err = client.Post("/projects/"+strconv.Itoa(project.ID)+"/variables", body) 37 | if err != nil { 38 | return fmt.Errorf("cannot create variable: %s", err) 39 | } 40 | return nil 41 | }, 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /cmd/delete/delete.go: -------------------------------------------------------------------------------- 1 | package delete 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/makkes/gitlab-cli/api" 7 | "github.com/makkes/gitlab-cli/cmd/delete/variable" 8 | "github.com/makkes/gitlab-cli/config" 9 | ) 10 | 11 | func NewCommand(client api.Client, cfg config.Config) *cobra.Command { 12 | var project *string 13 | cmd := &cobra.Command{ 14 | Use: "delete", 15 | Short: "Delete resources such as projects or variables", 16 | } 17 | 18 | project = cmd.PersistentFlags().StringP("project", "p", "", "If present, the project scope for this CLI request") 19 | 20 | cmd.AddCommand(variable.NewCommand(client, project)) 21 | 22 | return cmd 23 | } 24 | -------------------------------------------------------------------------------- /cmd/delete/variable/variable.go: -------------------------------------------------------------------------------- 1 | package variable 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "os" 7 | 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/makkes/gitlab-cli/api" 11 | ) 12 | 13 | func NewCommand(client api.Client, project *string) *cobra.Command { 14 | cmd := &cobra.Command{ 15 | Use: "vars KEY [KEY...]", 16 | Short: "Delete project-level variables", 17 | Args: cobra.MinimumNArgs(1), 18 | RunE: func(cmd *cobra.Command, args []string) (err error) { 19 | if project == nil || *project == "" { 20 | return fmt.Errorf("please provide a project scope") 21 | } 22 | // silence errors now since we already log an error for every single variable 23 | cmd.SilenceErrors = true 24 | project, err := client.FindProject(*project) 25 | if err != nil { 26 | fmt.Fprintf(os.Stderr, "Error removing variables: %s\n", err) 27 | return 28 | } 29 | for _, key := range args[0:] { 30 | status, err := client.Delete(fmt.Sprintf("/projects/%d/variables/%s", project.ID, url.PathEscape(key))) 31 | if err != nil { 32 | if status == 404 { 33 | fmt.Fprintf(os.Stderr, "Error: no such variable: %s\n", key) 34 | } else { 35 | fmt.Fprintf(os.Stderr, "Error removing variable %s: %s\n", key, err) 36 | } 37 | } else { 38 | fmt.Printf("Removed variable %s\n", key) 39 | } 40 | } 41 | return 42 | }, 43 | } 44 | 45 | return cmd 46 | } 47 | -------------------------------------------------------------------------------- /cmd/get/accesstokens/accesstokens.go: -------------------------------------------------------------------------------- 1 | package accesstokens 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/makkes/gitlab-cli/api" 11 | "github.com/makkes/gitlab-cli/cmd/get/output" 12 | "github.com/makkes/gitlab-cli/table" 13 | ) 14 | 15 | func NewCommand(client api.Client, format *string) *cobra.Command { 16 | var projectFlag string 17 | 18 | cmd := &cobra.Command{ 19 | Use: "access-tokens", 20 | Short: "List access tokens in a project", 21 | RunE: func(cmd *cobra.Command, args []string) error { 22 | if projectFlag == "" { 23 | fmt.Printf("missing project name/ID\n\n") 24 | return cmd.Usage() 25 | } 26 | 27 | p, err := client.FindProject(projectFlag) 28 | if err != nil { 29 | return fmt.Errorf("failed finding project: %w", err) 30 | } 31 | atl, err := client.GetAccessTokens(strconv.Itoa(p.ID)) 32 | if err != nil { 33 | return fmt.Errorf("error retrieving access tokens: %w", err) 34 | } 35 | 36 | return output.NewPrinter(os.Stdout).Print(*format, func() error { 37 | table.PrintProjectAccessTokens(os.Stdout, atl) 38 | return nil 39 | }, func() error { 40 | for _, at := range atl { 41 | fmt.Fprintf(os.Stdout, "%s\n", at.Name) 42 | } 43 | return nil 44 | }, atl) 45 | }, 46 | } 47 | 48 | cmd.Flags().StringVarP(&projectFlag, "project", "p", "", "project for which to list the tokens") 49 | 50 | return cmd 51 | } 52 | -------------------------------------------------------------------------------- /cmd/get/get.go: -------------------------------------------------------------------------------- 1 | package get 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/makkes/gitlab-cli/api" 7 | "github.com/makkes/gitlab-cli/cmd/get/accesstokens" 8 | "github.com/makkes/gitlab-cli/cmd/get/issues" 9 | "github.com/makkes/gitlab-cli/cmd/get/jobs" 10 | "github.com/makkes/gitlab-cli/cmd/get/logs" 11 | "github.com/makkes/gitlab-cli/cmd/get/output" 12 | "github.com/makkes/gitlab-cli/cmd/get/pipelines" 13 | "github.com/makkes/gitlab-cli/cmd/get/projects" 14 | "github.com/makkes/gitlab-cli/cmd/get/vars" 15 | "github.com/makkes/gitlab-cli/config" 16 | ) 17 | 18 | func NewCommand(client api.Client, cfg config.Config) *cobra.Command { 19 | var format *string 20 | cmd := &cobra.Command{ 21 | Use: "get", 22 | Short: "Display one or more objects", 23 | } 24 | 25 | format = output.AddFlag(cmd) 26 | 27 | cmd.AddCommand(issues.NewCommand(client, format)) 28 | cmd.AddCommand(pipelines.NewCommand(client, format)) 29 | cmd.AddCommand(projects.NewCommand(client, cfg, format)) 30 | cmd.AddCommand(vars.NewCommand(client, format)) 31 | cmd.AddCommand(jobs.NewCommand(client, format)) 32 | cmd.AddCommand(logs.NewCommand(client)) 33 | cmd.AddCommand(accesstokens.NewCommand(client, format)) 34 | 35 | return cmd 36 | } 37 | -------------------------------------------------------------------------------- /cmd/get/issues/issues.go: -------------------------------------------------------------------------------- 1 | package issues 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "os" 8 | "strconv" 9 | 10 | "github.com/spf13/cobra" 11 | 12 | "github.com/makkes/gitlab-cli/api" 13 | "github.com/makkes/gitlab-cli/cmd/get/output" 14 | "github.com/makkes/gitlab-cli/table" 15 | ) 16 | 17 | func issuesCommand(scope string, format string, client api.Client, all bool, page int, out io.Writer) error { 18 | project, err := client.FindProject(scope) 19 | if err != nil { 20 | return err 21 | } 22 | path := "/projects/" + strconv.Itoa(project.ID) + fmt.Sprintf("/issues?page=%d", page) 23 | if !all { 24 | path += "&state=opened" 25 | } 26 | resp, _, err := client.Get(path) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | issues := make([]api.Issue, 0) 32 | err = json.Unmarshal(resp, &issues) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | return output.NewPrinter(out).Print(format, func() error { 38 | table.PrintIssues(out, issues) 39 | return nil 40 | }, func() error { 41 | for _, issue := range issues { 42 | fmt.Fprintf(out, "%s\n", issue.Title) 43 | } 44 | return nil 45 | }, issues) 46 | } 47 | 48 | func NewCommand(client api.Client, format *string) *cobra.Command { 49 | var all *bool 50 | var page *int 51 | var project *string 52 | cmd := &cobra.Command{ 53 | Use: "issues", 54 | Short: "List issues in a project", 55 | RunE: func(cmd *cobra.Command, args []string) error { 56 | if project == nil || *project == "" { 57 | return fmt.Errorf("please provide a project scope") 58 | } 59 | return issuesCommand(*project, *format, client, *all, *page, os.Stdout) 60 | }, 61 | } 62 | 63 | project = cmd.PersistentFlags().StringP("project", "p", "", "If present, the project scope for this CLI request") 64 | all = cmd.Flags().BoolP("all", "a", false, "Show all issues (default shows just open)") 65 | page = cmd.Flags().Int("page", 1, "Page of results to display") 66 | return cmd 67 | } 68 | -------------------------------------------------------------------------------- /cmd/get/jobs/jobs.go: -------------------------------------------------------------------------------- 1 | package jobs 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/url" 7 | "os" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/spf13/cobra" 12 | 13 | "github.com/makkes/gitlab-cli/api" 14 | "github.com/makkes/gitlab-cli/cmd/get/output" 15 | "github.com/makkes/gitlab-cli/table" 16 | ) 17 | 18 | func NewCommand(client api.Client, format *string) *cobra.Command { 19 | var pipeline *string 20 | cmd := &cobra.Command{ 21 | Use: "jobs", 22 | Short: "List jobs of a pipeline", 23 | RunE: func(cmd *cobra.Command, args []string) error { 24 | if pipeline == nil || *pipeline == "" { 25 | return fmt.Errorf("please provide a pipeline scope") 26 | } 27 | ids := strings.Split(*pipeline, ":") 28 | if len(ids) < 2 || ids[0] == "" || ids[1] == "" { 29 | return fmt.Errorf("ID must be of the form PROJECT_ID:PIPELINE_ID") 30 | } 31 | resp, _, err := client.Get(fmt.Sprintf("/projects/%s/pipelines/%s/jobs", 32 | url.PathEscape(ids[0]), 33 | url.PathEscape(ids[1]))) 34 | if err != nil { 35 | return fmt.Errorf("error retrieving jobs: %w", err) 36 | } 37 | 38 | var respSlice []map[string]interface{} 39 | err = json.Unmarshal(resp, &respSlice) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | var jobs api.Jobs 45 | err = json.Unmarshal(resp, &jobs) 46 | if err != nil { 47 | return err 48 | } 49 | if len(jobs) == 0 { 50 | return nil 51 | } 52 | 53 | projectID, err := strconv.Atoi(ids[0]) 54 | if err != nil { 55 | return fmt.Errorf("error converting project ID '%s' to integer: %w", ids[0], err) 56 | } 57 | for idx := range jobs { 58 | jobs[idx].ProjectID = projectID 59 | } 60 | 61 | return output.NewPrinter(os.Stdout).Print(*format, func() error { 62 | table.PrintJobs(jobs) 63 | return nil 64 | }, func() error { 65 | for _, j := range jobs { 66 | fmt.Printf("%d\n", j.ID) 67 | } 68 | return nil 69 | }, jobs) 70 | }, 71 | } 72 | 73 | pipeline = cmd.PersistentFlags().StringP("pipeline", "p", "", "The pipeline to show jobs from") 74 | 75 | return cmd 76 | } 77 | -------------------------------------------------------------------------------- /cmd/get/logs/logs.go: -------------------------------------------------------------------------------- 1 | package logs 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/makkes/gitlab-cli/api" 11 | ) 12 | 13 | func NewCommand(client api.Client) *cobra.Command { 14 | cmd := &cobra.Command{ 15 | Use: "logs", 16 | Short: "Show logs of a job", 17 | Args: cobra.ExactArgs(1), 18 | RunE: func(cmd *cobra.Command, args []string) error { 19 | ids := strings.Split(args[0], ":") 20 | if len(ids) < 2 || ids[0] == "" || ids[1] == "" { 21 | return fmt.Errorf("ID must be of the form PROJECT_ID:JOB_ID") 22 | } 23 | logs, _, err := client.Get(fmt.Sprintf("/projects/%s/jobs/%s/trace", url.PathEscape(ids[0]), url.PathEscape(ids[1]))) 24 | if err != nil { 25 | return fmt.Errorf("error retrieving logs: %w", err) 26 | } 27 | 28 | fmt.Printf("%s\n", logs) 29 | 30 | return nil 31 | }, 32 | } 33 | 34 | return cmd 35 | } 36 | -------------------------------------------------------------------------------- /cmd/get/output/output.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "reflect" 9 | "strings" 10 | "text/template" 11 | 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | var ErrUnknownFormatRequested error = fmt.Errorf("unknown output format requested") 16 | 17 | type Printer struct { 18 | noListWithSingleEntry bool 19 | out io.Writer 20 | } 21 | 22 | type Opt func(p *Printer) 23 | 24 | func NewPrinter(out io.Writer, opts ...Opt) Printer { 25 | p := Printer{ 26 | out: out, 27 | } 28 | for _, opt := range opts { 29 | opt(&p) 30 | } 31 | return p 32 | } 33 | 34 | func NoListWithSingleEntry() Opt { 35 | return func(p *Printer) { 36 | p.noListWithSingleEntry = true 37 | } 38 | } 39 | 40 | func (p Printer) Print(format string, tableFunc func() error, nameFunc func() error, items interface{}) error { 41 | if p.noListWithSingleEntry && reflect.TypeOf(items).Kind() == reflect.Slice && reflect.ValueOf(items).Len() == 1 { 42 | items = reflect.ValueOf(items).Index(0).Interface() 43 | } 44 | 45 | switch { 46 | case format == "json": 47 | var buf bytes.Buffer 48 | enc := json.NewEncoder(&buf) 49 | enc.SetIndent("", " ") 50 | if err := enc.Encode(items); err != nil { 51 | return fmt.Errorf("error encoding items: %w", err) 52 | } 53 | if _, err := buf.WriteTo(p.out); err != nil { 54 | return err 55 | } 56 | _, err := p.out.Write([]byte("\n")) 57 | return err 58 | case format == "name": 59 | return nameFunc() 60 | case format == "table", format == "": 61 | return tableFunc() 62 | case strings.HasPrefix(format, "go-template="): 63 | tmplString := strings.TrimPrefix(format, "go-template=") 64 | tmpl, err := template.New("").Parse(tmplString) 65 | if err != nil { 66 | return fmt.Errorf("template parsing error: %s", err) 67 | } 68 | err = tmpl.Execute(p.out, items) 69 | if err != nil { 70 | return fmt.Errorf("template parsing error: %s", err) 71 | } 72 | return nil 73 | default: 74 | return ErrUnknownFormatRequested 75 | } 76 | } 77 | 78 | func AddFlag(cmd *cobra.Command) *string { 79 | return cmd.PersistentFlags().StringP("output", "o", "", "Output format. One of: json|name|table|go-template") 80 | } 81 | -------------------------------------------------------------------------------- /cmd/get/pipelines/pipelines.go: -------------------------------------------------------------------------------- 1 | package pipelines 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "strconv" 8 | 9 | "github.com/spf13/cobra" 10 | 11 | "github.com/makkes/gitlab-cli/api" 12 | "github.com/makkes/gitlab-cli/cmd/get/output" 13 | "github.com/makkes/gitlab-cli/table" 14 | ) 15 | 16 | func NewCommand(client api.Client, format *string) *cobra.Command { 17 | var all *bool 18 | var recent *bool 19 | var projectFlag *string 20 | 21 | cmd := &cobra.Command{ 22 | Use: "pipelines", 23 | Short: "List pipelines in a project", 24 | RunE: func(cmd *cobra.Command, args []string) error { 25 | if projectFlag == nil || *projectFlag == "" { 26 | return fmt.Errorf("please provide a project scope") 27 | } 28 | project, err := client.FindProject(*projectFlag) 29 | if err != nil { 30 | return fmt.Errorf("cannot list pipelines: %s", err) 31 | } 32 | resp, _, err := client.Get("/projects/" + strconv.Itoa(project.ID) + "/pipelines") 33 | if err != nil { 34 | return err 35 | } 36 | 37 | var respSlice []map[string]interface{} 38 | err = json.Unmarshal(resp, &respSlice) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | var pipelines api.Pipelines 44 | err = json.Unmarshal(resp, &pipelines) 45 | if err != nil { 46 | return err 47 | } 48 | if len(pipelines) == 0 { 49 | return nil 50 | } 51 | if *recent { 52 | pipelines = []api.Pipeline{pipelines[0]} 53 | respSlice = respSlice[0:1] 54 | } 55 | if len(pipelines) == 0 { 56 | return fmt.Errorf("no pipelines found for project '%s'", *projectFlag) 57 | } 58 | 59 | filteredRespSlice := make([]map[string]interface{}, 0) 60 | filteredPipelines := pipelines.Filter(func(idx int, p api.Pipeline) bool { 61 | include := *all || p.Status == "running" || p.Status == "pending" 62 | if include { 63 | filteredRespSlice = append(filteredRespSlice, respSlice[idx]) 64 | } 65 | return include 66 | }) 67 | 68 | pds := make([]api.PipelineDetails, 0) 69 | for _, p := range filteredPipelines { 70 | resp, _, err = client.Get("/projects/" + strconv.Itoa(project.ID) + "/pipelines/" + strconv.Itoa(p.ID)) 71 | if err != nil { 72 | return fmt.Errorf("error retrieving pipeline %d: %s", p.ID, err) 73 | } 74 | var pd api.PipelineDetails 75 | err = json.Unmarshal(resp, &pd) 76 | if err != nil { 77 | fmt.Println(err) 78 | } else { 79 | pd.ProjectID = project.ID 80 | pds = append(pds, pd) 81 | } 82 | } 83 | 84 | _, err = json.Marshal(filteredRespSlice) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | return output.NewPrinter(os.Stdout).Print(*format, func() error { 90 | table.PrintPipelines(pds) 91 | return nil 92 | }, func() error { 93 | for _, p := range pds { 94 | fmt.Printf("%d:%d\n", p.ProjectID, p.ID) 95 | } 96 | return nil 97 | }, pds) 98 | }, 99 | } 100 | 101 | projectFlag = cmd.PersistentFlags().StringP("project", "p", "", "If present, the project scope for this CLI request") 102 | all = cmd.Flags().BoolP("all", "a", false, "Show all pipelines (default shows just running/pending.)") 103 | recent = cmd.Flags().BoolP("recent", "r", false, "Show only the most recent pipeline") 104 | 105 | return cmd 106 | } 107 | -------------------------------------------------------------------------------- /cmd/get/projects/projects.go: -------------------------------------------------------------------------------- 1 | package projects 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "os" 8 | "strconv" 9 | 10 | "github.com/spf13/cobra" 11 | 12 | "github.com/makkes/gitlab-cli/api" 13 | "github.com/makkes/gitlab-cli/cmd/get/output" 14 | "github.com/makkes/gitlab-cli/config" 15 | "github.com/makkes/gitlab-cli/table" 16 | ) 17 | 18 | func projectsCommand( 19 | client api.Client, 20 | cfg config.Config, 21 | format string, 22 | page int, 23 | membership bool, 24 | out io.Writer) error { 25 | var path string 26 | if membership { 27 | path = fmt.Sprintf("/projects?membership=true&page=%d", page) 28 | } else { 29 | path = fmt.Sprintf("/users/${user}/projects?page=%d", page) 30 | } 31 | resp, status, err := client.Get(path) 32 | 33 | if err != nil { 34 | if status == 404 { 35 | return fmt.Errorf("cannot list projects: User %s not found. Please check your configuration", cfg.Get("user")) 36 | } 37 | return fmt.Errorf("cannot list projects: %s", err) 38 | } 39 | projects := make([]api.Project, 0) 40 | err = json.Unmarshal(resp, &projects) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | // put all project name => ID mappings into the cache 46 | for _, p := range projects { 47 | cfg.Cache().Put("projects", p.Name, strconv.Itoa(p.ID)) 48 | } 49 | cfg.Write() 50 | 51 | return output.NewPrinter(out).Print(format, func() error { 52 | table.PrintProjects(out, projects) 53 | return nil 54 | }, func() error { 55 | for _, p := range projects { 56 | fmt.Fprintf(out, "%s\n", p.Name) 57 | } 58 | return nil 59 | }, projects) 60 | } 61 | 62 | func NewCommand(client api.Client, cfg config.Config, format *string) *cobra.Command { 63 | var page *int 64 | var membership *bool 65 | 66 | cmd := &cobra.Command{ 67 | Use: "projects", 68 | Short: "List all your projects", 69 | RunE: func(cmd *cobra.Command, args []string) error { 70 | return projectsCommand(client, cfg, *format, *page, *membership, os.Stdout) 71 | }, 72 | } 73 | 74 | page = cmd.Flags().Int("page", 1, "Page of results to display") 75 | membership = cmd.Flags().Bool("member", false, "Displays projects you are a member of") 76 | 77 | return cmd 78 | } 79 | -------------------------------------------------------------------------------- /cmd/get/projects/projects_test.go: -------------------------------------------------------------------------------- 1 | package projects 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | 10 | "github.com/makkes/gitlab-cli/mock" 11 | ) 12 | 13 | func TestClientError(t *testing.T) { 14 | var out strings.Builder 15 | client := mock.Client{ 16 | Err: fmt.Errorf("Some client error"), 17 | } 18 | config := &mock.Config{ 19 | CacheData: &mock.Cache{}, 20 | } 21 | err := projectsCommand(client, config, "table", 0, false, &out) 22 | if err == nil { 23 | t.Error("Expected a non-nil error") 24 | } 25 | if err.Error() != "cannot list projects: Some client error" { 26 | t.Errorf("Unexpected error message '%s'", err) 27 | } 28 | if out.String() != "" { 29 | t.Errorf("Expected output to be empty but it is '%s'", out.String()) 30 | } 31 | } 32 | 33 | func TestUnknownProject(t *testing.T) { 34 | var out strings.Builder 35 | client := mock.Client{ 36 | Status: 404, 37 | Err: fmt.Errorf("Project not found"), 38 | } 39 | config := &mock.Config{ 40 | CacheData: &mock.Cache{}, 41 | Cfg: map[string]string{"user": "Dilbert"}, 42 | } 43 | err := projectsCommand(client, config, "table", 0, false, &out) 44 | if err == nil { 45 | t.Error("Expected a non-nil error") 46 | } 47 | if err.Error() != "cannot list projects: User Dilbert not found. Please check your configuration" { 48 | t.Errorf("Unexpected error message '%s'", err) 49 | } 50 | if out.String() != "" { 51 | t.Errorf("Expected output to be empty but it is '%s'", out.String()) 52 | } 53 | } 54 | 55 | func TestBrokenResponse(t *testing.T) { 56 | var out strings.Builder 57 | client := mock.Client{ 58 | Res: []byte("this is not JSON"), 59 | } 60 | config := &mock.Config{ 61 | CacheData: &mock.Cache{}, 62 | } 63 | err := projectsCommand(client, config, "table", 0, false, &out) 64 | if err == nil { 65 | t.Error("Expected a non-nil error") 66 | } 67 | if out.String() != "" { 68 | t.Errorf("Expected output to be empty but it is '%s'", out.String()) 69 | } 70 | } 71 | func TestEmptyResult(t *testing.T) { 72 | var out strings.Builder 73 | client := mock.Client{ 74 | Res: []byte(`[]`), 75 | } 76 | config := &mock.Config{ 77 | CacheData: &mock.Cache{}, 78 | } 79 | err := projectsCommand(client, config, "table", 0, false, &out) 80 | if err != nil { 81 | t.Errorf("Expected no error but got '%s'", err) 82 | } 83 | if out.String() != "ID NAME URL "+ 84 | " CLONE \n" { 85 | t.Errorf("Expected empty output but got '%s'", out.String()) 86 | } 87 | } 88 | 89 | func TestNameOutput(t *testing.T) { 90 | var out strings.Builder 91 | client := mock.Client{ 92 | Res: []byte(`[{"name": "123"}, {"name": "456"}]`), 93 | } 94 | config := &mock.Config{ 95 | CacheData: &mock.Cache{}, 96 | } 97 | err := projectsCommand(client, config, "name", 0, false, &out) 98 | if err != nil { 99 | t.Errorf("Expected no error but got '%s'", err) 100 | } 101 | if out.String() != "123\n456\n" { 102 | t.Errorf("Unexpected output '%s'", out.String()) 103 | } 104 | } 105 | 106 | func TestFormattedOutput(t *testing.T) { 107 | var out strings.Builder 108 | client := mock.Client{ 109 | Res: []byte(`[{"id": 123, "name":"broken arrow"}, {"id": 456, "name":"almanac"}]`), 110 | } 111 | config := &mock.Config{ 112 | CacheData: &mock.Cache{}, 113 | } 114 | err := projectsCommand(client, config, `go-template={{range .}}{{.Name}}{{"\n"}}{{end}}`, 0, false, &out) 115 | if err != nil { 116 | t.Errorf("Expected no error but got '%s'", err) 117 | } 118 | if out.String() != "broken arrow\nalmanac\n" { 119 | t.Errorf("Unexpected output '%s'", out.String()) 120 | } 121 | } 122 | func TestFormattedOutputError(t *testing.T) { 123 | var out strings.Builder 124 | client := mock.Client{ 125 | Res: []byte(`[{"id": 123, "name":"broken arrow"}, {"id": 456, "name":"almanac"}]`), 126 | } 127 | config := &mock.Config{ 128 | CacheData: &mock.Cache{}, 129 | } 130 | err := projectsCommand(client, config, "go-template={{.Broken}", 0, false, &out) 131 | if err == nil { 132 | t.Error("Expected a non-nil error") 133 | } 134 | if out.String() != "" { 135 | t.Errorf("Expected empty output but got '%s'", out.String()) 136 | } 137 | } 138 | 139 | type mockOutput struct { 140 | n int 141 | err error 142 | } 143 | 144 | func (m mockOutput) Write(p []byte) (n int, err error) { 145 | return m.n, m.err 146 | } 147 | func TestTemplateExecutionError(t *testing.T) { 148 | client := mock.Client{ 149 | Res: []byte(`[{"id": 123, "name":"broken arrow"}, {"id": 456, "name":"almanac"}]`), 150 | } 151 | config := &mock.Config{ 152 | CacheData: &mock.Cache{}, 153 | } 154 | err := projectsCommand(client, config, "go-template={{.Name}}", 0, false, &mockOutput{err: fmt.Errorf("some error")}) 155 | if err == nil { 156 | t.Error("Expected a non-nil error") 157 | } 158 | } 159 | 160 | func TestTableOutput(t *testing.T) { 161 | var out strings.Builder 162 | client := mock.Client{ 163 | Res: []byte(`[{"id": 123, "name":"broken arrow"}, {"id": 456, "name":"almanac"}]`), 164 | } 165 | config := &mock.Config{ 166 | CacheData: &mock.Cache{}, 167 | } 168 | err := projectsCommand(client, config, "table", 0, false, &out) 169 | if err != nil { 170 | t.Errorf("Expected no error but got '%s'", err) 171 | } 172 | require.Equal(t, `ID NAME URL `+ 173 | ` CLONE 174 | 123 broken arrow `+ 175 | ` 176 | 456 almanac `+ 177 | ` 178 | `, out.String()) 179 | } 180 | 181 | func TestEmptyTableOutput(t *testing.T) { 182 | var out strings.Builder 183 | client := mock.Client{ 184 | Res: []byte(`[]`), 185 | } 186 | config := &mock.Config{ 187 | CacheData: &mock.Cache{}, 188 | } 189 | err := projectsCommand(client, config, "table", 0, false, &out) 190 | if err != nil { 191 | t.Errorf("Expected no error but got '%s'", err) 192 | } 193 | if out.String() != "ID NAME URL "+ 194 | " CLONE \n" { 195 | t.Errorf("Unexpected output '%s'", out.String()) 196 | } 197 | } 198 | 199 | func TestCache(t *testing.T) { 200 | var out strings.Builder 201 | client := mock.Client{ 202 | Res: []byte(`[{"id": 123, "name": "p1"}, {"id": 456, "name":"p2"}]`), 203 | } 204 | cache := mock.Cache{} 205 | config := &mock.Config{ 206 | CacheData: &cache, 207 | } 208 | err := projectsCommand(client, config, "table", 0, false, &out) 209 | if err != nil { 210 | t.Errorf("Expected no error but got '%s'", err) 211 | } 212 | if len(cache.Calls) != 2 { 213 | t.Errorf("Expected two values to be cached but got %d", len(cache.Calls)) 214 | } 215 | call := cache.Calls[0] 216 | if call[0] != "projects" || call[1] != "p1" || call[2] != "123" { 217 | t.Errorf("Unexpected PUT call on cache: %s", call) 218 | } 219 | call = cache.Calls[1] 220 | if call[0] != "projects" || call[1] != "p2" || call[2] != "456" { 221 | t.Errorf("Unexpected PUT call on cache: %s", call) 222 | } 223 | if config.WriteCalls != 1 { 224 | t.Errorf("Expected the config to be written once but was %d", config.WriteCalls) 225 | } 226 | } 227 | 228 | func TestNewCommand(t *testing.T) { 229 | format := "table" 230 | cmd := NewCommand(mock.Client{}, &mock.Config{}, &format) 231 | flags := cmd.Flags() 232 | 233 | memberFlag := flags.Lookup("member") 234 | if memberFlag == nil { 235 | t.Fatalf("Expected 'member' flag to exist") 236 | } 237 | if memberFlag.Value.Type() != "bool" { 238 | t.Errorf("Expected 'member' flag to be a bool but is %s", memberFlag.Value.Type()) 239 | } 240 | if memberFlag.DefValue != "false" { 241 | t.Errorf("Expected default value of 'member' flag to be 'false' but is '%s'", memberFlag.DefValue) 242 | } 243 | 244 | pageFlag := flags.Lookup("page") 245 | if pageFlag == nil { 246 | t.Fatalf("Expected 'page' flag to exist") 247 | } 248 | if pageFlag.Value.Type() != "int" { 249 | t.Errorf("Expected 'page' flag to be a bool but is %s", pageFlag.Value.Type()) 250 | } 251 | if pageFlag.DefValue != "1" { 252 | t.Errorf("Expected default value of 'page' flag to be '1' but is '%s'", pageFlag.DefValue) 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /cmd/get/vars/vars.go: -------------------------------------------------------------------------------- 1 | package vars 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "strconv" 8 | 9 | "github.com/spf13/cobra" 10 | 11 | "github.com/makkes/gitlab-cli/api" 12 | "github.com/makkes/gitlab-cli/cmd/get/output" 13 | "github.com/makkes/gitlab-cli/table" 14 | ) 15 | 16 | func NewCommand(client api.Client, format *string) *cobra.Command { 17 | var project *string 18 | 19 | cmd := &cobra.Command{ 20 | Use: "vars [VARNAME]", 21 | Short: "List variables in a project", 22 | RunE: func(cmd *cobra.Command, args []string) error { 23 | if project == nil || *project == "" { 24 | return fmt.Errorf("please provide a project scope") 25 | } 26 | project, err := client.FindProject(*project) 27 | if err != nil { 28 | return fmt.Errorf("cannot list variables: %s", err) 29 | } 30 | resp, _, err := client.Get("/projects/" + strconv.Itoa(project.ID) + "/variables") 31 | if err != nil { 32 | return fmt.Errorf("cannot list variables: %s", err) 33 | } 34 | 35 | vars := make([]api.Var, 0) 36 | err = json.Unmarshal(resp, &vars) 37 | if err != nil { 38 | return fmt.Errorf("cannot list variables: %s", err) 39 | } 40 | if args[0] != "" { 41 | found := false 42 | for _, variable := range vars { 43 | if variable.Key == args[0] { 44 | found = true 45 | vars = []api.Var{variable} 46 | break 47 | } 48 | } 49 | if !found { 50 | // var requested that doesn't exist 51 | vars = []api.Var{} 52 | } 53 | } 54 | 55 | return output.NewPrinter( 56 | os.Stdout, 57 | output.NoListWithSingleEntry(), 58 | ).Print(*format, func() error { 59 | table.PrintVars(os.Stdout, vars) 60 | return nil 61 | }, func() error { 62 | for _, v := range vars { 63 | fmt.Fprintf(os.Stdout, "%s\n", v.Key) 64 | } 65 | return nil 66 | }, vars) 67 | }, 68 | } 69 | 70 | project = cmd.PersistentFlags().StringP("project", "p", "", "If present, the project scope for this CLI request") 71 | 72 | return cmd 73 | } 74 | -------------------------------------------------------------------------------- /cmd/gitlab/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | 7 | "github.com/makkes/gitlab-cli/cmd" 8 | "github.com/makkes/gitlab-cli/config" 9 | ) 10 | 11 | func main() { 12 | rand.Seed(time.Now().UnixNano()) 13 | cmd.Execute(config.Read()) 14 | } 15 | -------------------------------------------------------------------------------- /cmd/inspect/inspect.go: -------------------------------------------------------------------------------- 1 | package inspect 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/makkes/gitlab-cli/api" 7 | "github.com/makkes/gitlab-cli/cmd/inspect/issue" 8 | "github.com/makkes/gitlab-cli/cmd/inspect/pipeline" 9 | "github.com/makkes/gitlab-cli/cmd/inspect/project" 10 | ) 11 | 12 | func NewCommand(client api.Client) *cobra.Command { 13 | cmd := &cobra.Command{ 14 | Use: "inspect", 15 | Short: "Show details of a specific object", 16 | } 17 | 18 | cmd.AddCommand(issue.NewCommand(client)) 19 | cmd.AddCommand(pipeline.NewCommand(client)) 20 | cmd.AddCommand(project.NewCommand(client)) 21 | 22 | return cmd 23 | } 24 | -------------------------------------------------------------------------------- /cmd/inspect/inspect_test.go: -------------------------------------------------------------------------------- 1 | package inspect 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/makkes/gitlab-cli/mock" 7 | ) 8 | 9 | func TestSubCommands(t *testing.T) { 10 | cmd := NewCommand(mock.Client{}) 11 | subCmds := cmd.Commands() 12 | if len(subCmds) != 3 { 13 | t.Errorf("Expected 1 sub-command but got %d", len(subCmds)) 14 | } 15 | if cmd.UseLine() != "inspect" { 16 | t.Errorf("Unexpected usage line '%s'", cmd.UseLine()) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /cmd/inspect/issue/issue.go: -------------------------------------------------------------------------------- 1 | package issue 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "os" 9 | "strings" 10 | 11 | "github.com/spf13/cobra" 12 | 13 | "github.com/makkes/gitlab-cli/api" 14 | ) 15 | 16 | func inspectCommand(client api.Client, args []string, out io.Writer) error { 17 | ids := strings.Split(args[0], ":") 18 | if len(ids) < 2 || len(ids[0]) == 0 || len(ids[1]) == 0 { 19 | return fmt.Errorf("ID must be of the form PROJECT_ID:ISSUE_ID") 20 | } 21 | 22 | resp, status, err := client.Get("/projects/" + ids[0] + "/issues/" + ids[1]) 23 | if err != nil { 24 | if status == 404 { 25 | return fmt.Errorf("issue %s not found", args[0]) 26 | } 27 | return err 28 | } 29 | var buf bytes.Buffer 30 | if err := json.Indent(&buf, resp, "", " "); err != nil { 31 | return err 32 | } 33 | if _, err := buf.WriteTo(out); err != nil { 34 | return err 35 | } 36 | _, err = out.Write([]byte("\n")) 37 | return err 38 | } 39 | 40 | func NewCommand(client api.Client) *cobra.Command { 41 | cmd := &cobra.Command{ 42 | Use: "issue ID", 43 | Short: "Display detailed information on an issue", 44 | Args: cobra.ExactArgs(1), 45 | RunE: func(cmd *cobra.Command, args []string) error { 46 | return inspectCommand(client, args, os.Stdout) 47 | }, 48 | } 49 | return cmd 50 | } 51 | -------------------------------------------------------------------------------- /cmd/inspect/issue/issue_test.go: -------------------------------------------------------------------------------- 1 | package issue 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | 10 | "github.com/makkes/gitlab-cli/mock" 11 | ) 12 | 13 | func TestWrongArgFormat(t *testing.T) { 14 | in := []struct { 15 | name string 16 | args []string 17 | }{ 18 | {"empty", []string{""}}, 19 | {"no colon", []string{"ab"}}, 20 | {"no 1st arg", []string{":b"}}, 21 | {"no 2nd arg", []string{"a:"}}, 22 | } 23 | for _, tt := range in { 24 | t.Run(tt.name, func(t *testing.T) { 25 | var out strings.Builder 26 | err := inspectCommand(mock.Client{}, tt.args, &out) 27 | if out.String() != "" { 28 | t.Errorf("Expected output to be empty but it is '%s'", out.String()) 29 | } 30 | if err == nil { 31 | t.Errorf("Expected a non-nil error") 32 | } 33 | if err.Error() != "ID must be of the form PROJECT_ID:ISSUE_ID" { 34 | t.Errorf("Expected error message to be '' but is '%s'", err.Error()) 35 | } 36 | }) 37 | } 38 | } 39 | 40 | func TestUnknownProject(t *testing.T) { 41 | var out strings.Builder 42 | client := mock.Client{ 43 | Status: 404, 44 | Err: fmt.Errorf("Project not found"), 45 | } 46 | err := inspectCommand(client, []string{"pid:iid"}, &out) 47 | if err == nil { 48 | t.Errorf("Expected non-nil error") 49 | } 50 | require.Equal(t, "issue pid:iid not found", err.Error(), "unexpected error") 51 | if out.String() != "" { 52 | t.Errorf("Expected output to be empty but it is '%s'", out.String()) 53 | } 54 | } 55 | 56 | func TestUnknownClientError(t *testing.T) { 57 | var out strings.Builder 58 | client := mock.Client{ 59 | Status: 500, 60 | Err: fmt.Errorf("Server broken"), 61 | } 62 | err := inspectCommand(client, []string{"pid:iid"}, &out) 63 | if err == nil { 64 | t.Errorf("Expected non-nil error") 65 | } 66 | if err.Error() != "Server broken" { 67 | t.Errorf("Unexpected error '%s'", err.Error()) 68 | } 69 | if out.String() != "" { 70 | t.Errorf("Expected output to be empty but it is '%s'", out.String()) 71 | } 72 | } 73 | 74 | func TestHappyPath(t *testing.T) { 75 | var out strings.Builder 76 | client := mock.Client{ 77 | Res: []byte("\"hello\""), 78 | } 79 | err := inspectCommand(client, []string{"pid:iid"}, &out) 80 | if err != nil { 81 | t.Errorf("Expected no error but got '%s'", err) 82 | } 83 | if out.String() != "\"hello\"\n" { 84 | t.Errorf("Expected output to be empty but it is '%s'", out.String()) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /cmd/inspect/pipeline/pipeline.go: -------------------------------------------------------------------------------- 1 | package pipeline 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "strings" 9 | 10 | "github.com/spf13/cobra" 11 | 12 | "github.com/makkes/gitlab-cli/api" 13 | ) 14 | 15 | func NewCommand(client api.Client) *cobra.Command { 16 | return &cobra.Command{ 17 | Use: "pipeline ID", 18 | Short: "List details of a pipeline", 19 | Args: cobra.ExactArgs(1), 20 | RunE: func(cmd *cobra.Command, args []string) error { 21 | ids := strings.Split(args[0], ":") 22 | if len(ids) < 2 || ids[0] == "" || ids[1] == "" { 23 | return fmt.Errorf("ID must be of the form PROJECT_ID:PIPELINE_ID") 24 | } 25 | pipeline, err := client.GetPipelineDetails(ids[0], ids[1]) 26 | if err != nil { 27 | return fmt.Errorf("cannot show pipeline: %s", err) 28 | } 29 | var out bytes.Buffer 30 | if err := json.Indent(&out, pipeline, "", " "); err != nil { 31 | return err 32 | } 33 | if _, err := out.WriteTo(os.Stdout); err != nil { 34 | return nil 35 | } 36 | fmt.Println() 37 | return nil 38 | }, 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /cmd/inspect/project/projet.go: -------------------------------------------------------------------------------- 1 | package project 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "os" 9 | 10 | "github.com/spf13/cobra" 11 | 12 | "github.com/makkes/gitlab-cli/api" 13 | ) 14 | 15 | func inspectCommand(client api.Client, args []string, out io.Writer) error { 16 | project, err := client.FindProjectDetails(args[0]) 17 | if err != nil { 18 | return fmt.Errorf("cannot show project: %s", err) 19 | } 20 | var buf bytes.Buffer 21 | if err := json.Indent(&buf, project, "", " "); err != nil { 22 | return err 23 | } 24 | if _, err := buf.WriteTo(out); err != nil { 25 | return err 26 | } 27 | _, err = out.Write([]byte("\n")) 28 | return err 29 | } 30 | 31 | func NewCommand(client api.Client) *cobra.Command { 32 | cmd := &cobra.Command{ 33 | Use: "project ID", 34 | Short: "Display detailed information on a project", 35 | Args: cobra.ExactArgs(1), 36 | RunE: func(cmd *cobra.Command, args []string) error { 37 | return inspectCommand(client, args, os.Stdout) 38 | }, 39 | } 40 | return cmd 41 | } 42 | -------------------------------------------------------------------------------- /cmd/login/login.go: -------------------------------------------------------------------------------- 1 | package login 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/makkes/gitlab-cli/api" 9 | "github.com/makkes/gitlab-cli/config" 10 | ) 11 | 12 | func NewCommand(client api.Client, cfg config.Config) *cobra.Command { 13 | return &cobra.Command{ 14 | Use: "login [URL]", 15 | Short: "Login to GitLab. If URL is omitted then https://gitlab.com is used.", 16 | Args: cobra.MaximumNArgs(1), 17 | RunE: func(cmd *cobra.Command, args []string) error { 18 | url := "https://gitlab.com" 19 | if len(args) == 2 { 20 | url = args[1] 21 | } 22 | 23 | token, err := readPasswordFromStdin("Please enter your GitLab personal access token (PAT): ") 24 | if err != nil { 25 | return fmt.Errorf("failed reading token from stdin: %w", err) 26 | } 27 | 28 | username, err := client.Login(token, url) 29 | if err != nil { 30 | return fmt.Errorf("cannot login to %s: %s", url, err) 31 | } 32 | fmt.Printf("Logged in as %s\n", username) 33 | cfg.Write() 34 | return nil 35 | }, 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /cmd/login/term.go: -------------------------------------------------------------------------------- 1 | package login 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "golang.org/x/term" 10 | ) 11 | 12 | func readPasswordFromStdin(prompt string) (string, error) { 13 | var out string 14 | var err error 15 | fmt.Fprint(os.Stdout, prompt) 16 | stdinFD := int(os.Stdin.Fd()) 17 | if term.IsTerminal(stdinFD) { 18 | var inBytes []byte 19 | inBytes, err = term.ReadPassword(int(os.Stdin.Fd())) 20 | out = string(inBytes) 21 | } else { 22 | out, err = bufio.NewReader(os.Stdin).ReadString('\n') 23 | } 24 | if err != nil { 25 | return "", fmt.Errorf("could not read from stdin: %w", err) 26 | } 27 | fmt.Println() 28 | return strings.TrimRight(out, "\r\n"), nil 29 | } 30 | -------------------------------------------------------------------------------- /cmd/revoke/accesstoken/accesstoken.go: -------------------------------------------------------------------------------- 1 | package accesstoken 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/makkes/gitlab-cli/api" 11 | ) 12 | 13 | func NewCommand(client api.Client) *cobra.Command { 14 | var projectFlag string 15 | 16 | cmd := &cobra.Command{ 17 | Use: "access-token ID|NAME", 18 | Args: cobra.ExactArgs(1), 19 | Short: "Revoke a project access token", 20 | RunE: func(cmd *cobra.Command, args []string) error { 21 | if projectFlag == "" { 22 | fmt.Printf("missing project name/ID\n\n") 23 | return cmd.Usage() 24 | } 25 | 26 | p, err := client.FindProject(projectFlag) 27 | if err != nil { 28 | return fmt.Errorf("failed to find project %q: %w", projectFlag, err) 29 | } 30 | 31 | atID := -1 32 | atl, err := client.GetAccessTokens(strconv.Itoa(p.ID)) 33 | if err != nil { 34 | return fmt.Errorf("failed to list access tokens: %w", err) 35 | } 36 | 37 | reqAtID, _ := strconv.Atoi(args[0]) 38 | for _, at := range atl { 39 | if at.Name == args[0] { 40 | atID = at.ID 41 | break 42 | } 43 | if at.ID == reqAtID { 44 | atID = at.ID 45 | break 46 | } 47 | } 48 | 49 | if atID == -1 { 50 | return fmt.Errorf("access token %q not found", args[0]) 51 | } 52 | 53 | sc, err := client.Delete(fmt.Sprintf("/projects/%d/access_tokens/%d", p.ID, atID)) 54 | if err != nil { 55 | return fmt.Errorf("failed revoking token: %w", err) 56 | } 57 | 58 | if sc != http.StatusNoContent { 59 | return fmt.Errorf("received unexpected status code from GitLab API: %d", sc) 60 | } 61 | 62 | fmt.Printf("revoked access token %d in %q\n", atID, p.Name) 63 | 64 | return nil 65 | }, 66 | } 67 | 68 | cmd.Flags().StringVarP(&projectFlag, "project", "p", "", "project to revoke the token from") 69 | 70 | return cmd 71 | } 72 | -------------------------------------------------------------------------------- /cmd/revoke/revoke.go: -------------------------------------------------------------------------------- 1 | package revoke 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/makkes/gitlab-cli/api" 7 | "github.com/makkes/gitlab-cli/cmd/revoke/accesstoken" 8 | ) 9 | 10 | func NewCommand(client api.Client) *cobra.Command { 11 | cmd := &cobra.Command{ 12 | Use: "revoke", 13 | Short: "Revoke an object, e.g. a project access token", 14 | } 15 | 16 | cmd.AddCommand(accesstoken.NewCommand(client)) 17 | 18 | return cmd 19 | } 20 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/makkes/gitlab-cli/api" 9 | apicmd "github.com/makkes/gitlab-cli/cmd/api" 10 | "github.com/makkes/gitlab-cli/cmd/completion" 11 | "github.com/makkes/gitlab-cli/cmd/create" 12 | "github.com/makkes/gitlab-cli/cmd/delete" 13 | "github.com/makkes/gitlab-cli/cmd/get" 14 | "github.com/makkes/gitlab-cli/cmd/inspect" 15 | "github.com/makkes/gitlab-cli/cmd/login" 16 | "github.com/makkes/gitlab-cli/cmd/revoke" 17 | "github.com/makkes/gitlab-cli/cmd/status" 18 | "github.com/makkes/gitlab-cli/cmd/update" 19 | "github.com/makkes/gitlab-cli/cmd/version" 20 | "github.com/makkes/gitlab-cli/config" 21 | ) 22 | 23 | var rootCmd = &cobra.Command{ 24 | Use: "gitlab", 25 | SilenceUsage: true, 26 | } 27 | 28 | func Execute(cfg config.Config) { 29 | apiClient := api.NewAPIClient(cfg) 30 | 31 | rootCmd.AddCommand(apicmd.NewCommand(apiClient, cfg)) 32 | rootCmd.AddCommand(inspect.NewCommand(apiClient)) 33 | rootCmd.AddCommand(get.NewCommand(apiClient, cfg)) 34 | rootCmd.AddCommand(create.NewCommand(apiClient, cfg)) 35 | rootCmd.AddCommand(delete.NewCommand(apiClient, cfg)) 36 | 37 | rootCmd.AddCommand(login.NewCommand(apiClient, cfg)) 38 | rootCmd.AddCommand(status.NewCommand(apiClient, cfg)) 39 | rootCmd.AddCommand(completion.NewCommand(rootCmd)) 40 | rootCmd.AddCommand(version.NewCommand()) 41 | rootCmd.AddCommand(update.NewCommand()) 42 | rootCmd.AddCommand(revoke.NewCommand(apiClient)) 43 | 44 | if err := rootCmd.Execute(); err != nil { 45 | os.Exit(1) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /cmd/status/status.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/makkes/gitlab-cli/api" 9 | "github.com/makkes/gitlab-cli/config" 10 | ) 11 | 12 | func NewCommand(client api.Client, cfg config.Config) *cobra.Command { 13 | return &cobra.Command{ 14 | Use: "status", 15 | Short: "Display the current configuration of GitLab CLI", 16 | RunE: func(cmd *cobra.Command, args []string) error { 17 | url := cfg.Get("url") 18 | if url == "" { 19 | return fmt.Errorf("GitLab CLI is not configured, yet. Run »gitlab login« first") 20 | } 21 | fmt.Printf("Logged in at %s as %s\n", url, cfg.Get("user")) 22 | return nil 23 | }, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /cmd/update/releases_test.atom: -------------------------------------------------------------------------------- 1 | 2 | 3 | tag:github.com,2008:https://github.com/makkes/gitlab-cli/releases 4 | 5 | 6 | Release notes from gitlab-cli 7 | 2019-12-24T08:51:05Z 8 | 9 | tag:github.com,2008:Repository/168714048/v3.8.0-beta.1 10 | 2019-12-24T08:58:17Z 11 | 12 | v3.8.0-beta.1 13 | Pre-release with great new stuff 14 | 15 | makkes 16 | 17 | 18 | 19 | 20 | tag:github.com,2008:Repository/168714048/v3.6.3 21 | 2019-12-23T08:58:17Z 22 | 23 | 3.6.3 24 | <h1>Minor enhancements</h1> 25 | <ul> 26 | <li>Add '--dry-run' flag to the 'update' cmd</li> 27 | </ul> 28 | 29 | makkes 30 | 31 | 32 | 33 | 34 | tag:github.com,2008:Repository/168714048/v3.6.2 35 | 2019-12-23T08:55:37Z 36 | 37 | 3.6.2 38 | <h1>Minor enhancements</h1> 39 | <ul> 40 | <li>Fix <code>update</code> command when run from <code>$PATH</code></li> 41 | </ul> 42 | 43 | makkes 44 | 45 | 46 | 47 | 48 | tag:github.com,2008:Repository/168714048/v3.6.1 49 | 2019-12-23T08:56:45Z 50 | 51 | 3.6.1 52 | <h1>Minor enhancements</h1> 53 | <ul> 54 | <li>Make <code>update</code> command more robust</li> 55 | </ul> 56 | 57 | makkes 58 | 59 | 60 | 61 | 62 | tag:github.com,2008:Repository/168714048/v3.6.0 63 | 2019-12-22T21:21:24Z 64 | 65 | 3.6.0 66 | <h1>Major improvements</h1> 67 | <ul> 68 | <li>Add <code>update</code> command (<a class="issue-link js-issue-link" data-error-text="Failed to load issue title" data-id="541499883" data-permission-text="Issue title is private" data-url="https://github.com/makkes/gitlab-cli/issues/40" data-hovercard-type="pull_request" data-hovercard-url="/makkes/gitlab-cli/pull/40/hovercard" href="https://github.com/makkes/gitlab-cli/pull/40">#40</a>)</li> 69 | </ul> 70 | <h1>Contributors to this release</h1> 71 | <ul> 72 | <li><a class="user-mention" data-hovercard-type="user" data-hovercard-url="/users/makkes/hovercard" data-octo-click="hovercard-link-click" data-octo-dimensions="link_type:self" href="https://github.com/makkes">@makkes</a></li> 73 | </ul> 74 | 75 | makkes 76 | 77 | 78 | 79 | 80 | tag:github.com,2008:Repository/168714048/v3.5.0 81 | 2019-12-14T12:30:08Z 82 | 83 | 3.5.0 84 | <h1>Major improvements</h1> 85 | <ul> 86 | <li>Add optional <code>environment_scope</code> argument to <code>var create</code> (<a class="issue-link js-issue-link" data-error-text="Failed to load issue title" data-id="508614896" data-permission-text="Issue title is private" data-url="https://github.com/makkes/gitlab-cli/issues/34" data-hovercard-type="pull_request" data-hovercard-url="/makkes/gitlab-cli/pull/34/hovercard" href="https://github.com/makkes/gitlab-cli/pull/34">#34</a>).</li> 87 | </ul> 88 | <h1>Minor changes</h1> 89 | <ul> 90 | <li>don't print warning when config file doesn't exist (<a class="issue-link js-issue-link" data-error-text="Failed to load issue title" data-id="537906111" data-permission-text="Issue title is private" data-url="https://github.com/makkes/gitlab-cli/issues/38" data-hovercard-type="pull_request" data-hovercard-url="/makkes/gitlab-cli/pull/38/hovercard" href="https://github.com/makkes/gitlab-cli/pull/38">#38</a>)</li> 91 | </ul> 92 | <h1>Contributors to this release</h1> 93 | <ul> 94 | <li><a class="user-mention" data-hovercard-type="user" data-hovercard-url="/users/erikthered/hovercard" data-octo-click="hovercard-link-click" data-octo-dimensions="link_type:self" href="https://github.com/erikthered">@erikthered</a></li> 95 | <li><a class="user-mention" data-hovercard-type="user" data-hovercard-url="/users/makkes/hovercard" data-octo-click="hovercard-link-click" data-octo-dimensions="link_type:self" href="https://github.com/makkes">@makkes</a></li> 96 | </ul> 97 | 98 | makkes 99 | 100 | 101 | 102 | 103 | tag:github.com,2008:Repository/168714048/v3.4.0 104 | 2019-10-15T13:37:26Z 105 | 106 | 3.4.0 107 | <h1>Major improvements</h1> 108 | <ul> 109 | <li>Add <code>--page</code> parameter to <code>projects</code> and <code>issues</code> commands (<a class="issue-link js-issue-link" data-error-text="Failed to load issue title" data-id="504894809" data-permission-text="Issue title is private" data-url="https://github.com/makkes/gitlab-cli/issues/29" data-hovercard-type="pull_request" data-hovercard-url="/makkes/gitlab-cli/pull/29/hovercard" href="https://github.com/makkes/gitlab-cli/pull/29">#29</a>).</li> 110 | <li>Add environment scope column to var command output (<a class="issue-link js-issue-link" data-error-text="Failed to load issue title" data-id="505913060" data-permission-text="Issue title is private" data-url="https://github.com/makkes/gitlab-cli/issues/30" data-hovercard-type="pull_request" data-hovercard-url="/makkes/gitlab-cli/pull/30/hovercard" href="https://github.com/makkes/gitlab-cli/pull/30">#30</a>).</li> 111 | </ul> 112 | <h1>Contributors to this release</h1> 113 | <ul> 114 | <li><a class="user-mention" data-hovercard-type="user" data-hovercard-url="/users/erikthered/hovercard" data-octo-click="hovercard-link-click" data-octo-dimensions="link_type:self" href="https://github.com/erikthered">@erikthered</a></li> 115 | <li><a class="user-mention" data-hovercard-type="user" data-hovercard-url="/users/makkes/hovercard" data-octo-click="hovercard-link-click" data-octo-dimensions="link_type:self" href="https://github.com/makkes">@makkes</a></li> 116 | </ul> 117 | 118 | makkes 119 | 120 | 121 | 122 | 123 | tag:github.com,2008:Repository/168714048/v3.3.0 124 | 2019-10-09T09:19:45Z 125 | 126 | 3.3.0 127 | <h1>Major improvements</h1> 128 | <p>This release features two new contributors: <a class="user-mention" data-hovercard-type="user" data-hovercard-url="/users/erikthered/hovercard" data-octo-click="hovercard-link-click" data-octo-dimensions="link_type:self" href="https://github.com/erikthered">@erikthered</a> and <a class="user-mention" data-hovercard-type="user" data-hovercard-url="/users/bfontaine/hovercard" data-octo-click="hovercard-link-click" data-octo-dimensions="link_type:self" href="https://github.com/bfontaine">@bfontaine</a> <g-emoji class="g-emoji" alias="tada" fallback-src="https://github.githubassets.com/images/icons/emoji/unicode/1f389.png">🎉</g-emoji></p> 129 | <h1>Minor changes</h1> 130 | <ul> 131 | <li>Improved help output of <code>var create</code> (<a class="issue-link js-issue-link" data-error-text="Failed to load issue title" data-id="499447213" data-permission-text="Issue title is private" data-url="https://github.com/makkes/gitlab-cli/issues/20" data-hovercard-type="issue" data-hovercard-url="/makkes/gitlab-cli/issues/20/hovercard" href="https://github.com/makkes/gitlab-cli/issues/20">#20</a>). Thanks to <a class="user-mention" data-hovercard-type="user" data-hovercard-url="/users/erikthered/hovercard" data-octo-click="hovercard-link-click" data-octo-dimensions="link_type:self" href="https://github.com/erikthered">@erikthered</a> for raising that.</li> 132 | <li>Renamed the binary from <code>gitlab-cli</code> to <code>gitlab</code> (<a class="issue-link js-issue-link" data-error-text="Failed to load issue title" data-id="503918009" data-permission-text="Issue title is private" data-url="https://github.com/makkes/gitlab-cli/issues/25" data-hovercard-type="issue" data-hovercard-url="/makkes/gitlab-cli/issues/25/hovercard" href="https://github.com/makkes/gitlab-cli/issues/25">#25</a>). Thanks to <a class="user-mention" data-hovercard-type="user" data-hovercard-url="/users/bfontaine/hovercard" data-octo-click="hovercard-link-click" data-octo-dimensions="link_type:self" href="https://github.com/bfontaine">@bfontaine</a> for the contribution.</li> 133 | </ul> 134 | 135 | makkes 136 | 137 | 138 | 139 | 140 | tag:github.com,2008:Repository/168714048/v3.2.0 141 | 2019-09-20T07:45:05Z 142 | 143 | 3.2.0 144 | <p>This release doesn't ship any changes to the CLI itself but rather is the first release that ships binaries for Linux, Windows and macOS.</p> 145 | 146 | makkes 147 | 148 | 149 | 150 | 151 | tag:github.com,2008:Repository/168714048/v3.1.0 152 | 2019-09-17T18:56:15Z 153 | 154 | 3.1.0 155 | <h1>Major improvements</h1> 156 | <h2>New commands</h2> 157 | <ul> 158 | <li>Added <code>version</code> command to display the version of GitLab CLI</li> 159 | </ul> 160 | 161 | makkes 162 | 163 | 164 | 165 | 166 | tag:github.com,2008:Repository/168714048/v3.0.0 167 | 2019-02-21T21:47:59Z 168 | 169 | 3.0.0 170 | <h1>Major improvements</h1> 171 | <h2>New commands</h2> 172 | <ul> 173 | <li>Added <code>var</code> command to create/list/remove project variables</li> 174 | <li>Added <code>status</code> command to see login status</li> 175 | <li>Added <code>completion</code> cmd to generate Bash completion script</li> 176 | </ul> 177 | <h2>Changed commands</h2> 178 | <ul> 179 | <li>The <code>projects</code> table now contains the project's clone URL</li> 180 | <li>Reworked the <code>project</code> cmd; it only has sub-cmds now</li> 181 | <li>The <code>project create</code> cmd has more useful output now</li> 182 | </ul> 183 | <h1>Minor changes</h1> 184 | <ul> 185 | <li>Better UX: Include URL in error message when login fails</li> 186 | </ul> 187 | 188 | makkes 189 | 190 | 191 | 192 | 193 | -------------------------------------------------------------------------------- /cmd/update/releases_test_major_upgrade.atom: -------------------------------------------------------------------------------- 1 | 2 | 3 | tag:github.com,2008:https://github.com/makkes/gitlab-cli/releases 4 | 5 | 6 | Release notes from gitlab-cli 7 | 2019-12-24T08:51:05Z 8 | 9 | tag:github.com,2008:Repository/168714048/v4.0.0 10 | 2019-12-24T08:58:17Z 11 | 12 | v4.0.0 13 | New major version with great new stuff 14 | 15 | makkes 16 | 17 | 18 | 19 | 20 | tag:github.com,2008:Repository/168714048/v3.6.3 21 | 2019-12-23T08:58:17Z 22 | 23 | 3.6.3 24 | <h1>Minor enhancements</h1> 25 | <ul> 26 | <li>Add '--dry-run' flag to the 'update' cmd</li> 27 | </ul> 28 | 29 | makkes 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /cmd/update/releases_test_major_upgrade_pre.atom: -------------------------------------------------------------------------------- 1 | 2 | 3 | tag:github.com,2008:https://github.com/makkes/gitlab-cli/releases 4 | 5 | 6 | Release notes from gitlab-cli 7 | 2019-12-24T08:51:05Z 8 | 9 | tag:github.com,2008:Repository/168714048/v4.0.0-beta.1 10 | 2019-12-24T08:58:17Z 11 | 12 | v4.0.0-beta.1 13 | New major version with great new stuff 14 | 15 | makkes 16 | 17 | 18 | 19 | 20 | tag:github.com,2008:Repository/168714048/v3.6.3 21 | 2019-12-23T08:58:17Z 22 | 23 | 3.6.3 24 | <h1>Minor enhancements</h1> 25 | <ul> 26 | <li>Add '--dry-run' flag to the 'update' cmd</li> 27 | </ul> 28 | 29 | makkes 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /cmd/update/update.go: -------------------------------------------------------------------------------- 1 | package update 2 | 3 | import ( 4 | "archive/tar" 5 | "compress/gzip" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "os" 10 | "runtime" 11 | 12 | "github.com/blang/semver/v4" 13 | "github.com/spf13/cobra" 14 | 15 | "github.com/makkes/gitlab-cli/config" 16 | "github.com/makkes/gitlab-cli/versions" 17 | ) 18 | 19 | var repo = "https://github.com/makkes/gitlab-cli" 20 | 21 | func updateCommand(dryRun bool, includePreReleases bool, upgradeMajor bool, out io.Writer, 22 | getExecutable func() (string, error)) error { 23 | currentVersion, err := semver.ParseTolerant(config.Version) 24 | if err != nil { 25 | return fmt.Errorf("could not parse current version '%s': %w", config.Version, err) 26 | } 27 | latestVersionString, err := versions.LatestVersion(repo, currentVersion, upgradeMajor, includePreReleases) 28 | if err != nil { 29 | return fmt.Errorf("could not fetch latest version: %w", err) 30 | } 31 | latestVersion, err := semver.ParseTolerant(latestVersionString) 32 | if err != nil { 33 | return fmt.Errorf("could not parse latest version '%s': %w", latestVersionString, err) 34 | } 35 | if currentVersion.Compare(latestVersion) == -1 { 36 | if dryRun { 37 | fmt.Fprintf(out, "A new version is available: %s\nSee %s for details\n", 38 | latestVersionString, repo+"/releases/"+latestVersionString) 39 | return nil 40 | } 41 | downloadURL := fmt.Sprintf("%s/releases/download/%s/gitlab_%s_%s_%s.tar.gz", 42 | repo, latestVersionString, latestVersionString, runtime.GOOS, runtime.GOARCH) 43 | fmt.Fprintf(out, "Updating to %s\n", latestVersionString) 44 | resp, err := http.Get(downloadURL) // #nosec G107 45 | if err != nil { 46 | return fmt.Errorf("could not download latest release from %s: %w", downloadURL, err) 47 | } 48 | if resp.StatusCode != http.StatusOK { 49 | return fmt.Errorf("could not download latest release from %s: received HTTP status %d", downloadURL, resp.StatusCode) 50 | } 51 | 52 | exec, err := getExecutable() 53 | if err != nil { 54 | return fmt.Errorf("could not get current executable to update: %w", err) 55 | } 56 | dest := exec + ".new" 57 | stat, err := os.Stat(exec) 58 | if err != nil { 59 | return fmt.Errorf("could not stat binary for updating: %w", err) 60 | } 61 | binary, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE, stat.Mode()) 62 | if err != nil { 63 | return fmt.Errorf("could not open binary for updating: %w", err) 64 | } 65 | defer func() { 66 | os.Remove(dest) 67 | }() 68 | 69 | gzReader, err := gzip.NewReader(resp.Body) 70 | if err != nil { 71 | return fmt.Errorf("failed creating gzip reader: %w", err) 72 | } 73 | defer gzReader.Close() 74 | 75 | tarReader := tar.NewReader(gzReader) 76 | if _, err := tarReader.Next(); err != nil { 77 | return fmt.Errorf("failed advancing in tar archive: %w", err) 78 | } 79 | 80 | _, err = io.Copy(binary, tarReader) 81 | binary.Close() 82 | if err != nil { 83 | return fmt.Errorf("could not download new version: %w", err) 84 | } 85 | err = os.Rename(dest, exec) 86 | if err != nil { 87 | return fmt.Errorf("could not update to new version: %w", err) 88 | } 89 | } else { 90 | fmt.Fprintf(out, "You're already on the latest version %s.\n", config.Version) 91 | return nil 92 | } 93 | return nil 94 | } 95 | 96 | func NewCommand() *cobra.Command { 97 | var dryRun *bool 98 | var upgradeMajor *bool 99 | var includePre *bool 100 | 101 | cmd := &cobra.Command{ 102 | Use: "update", 103 | Short: "Update GitLab CLI to latest version", 104 | Args: cobra.MaximumNArgs(1), 105 | RunE: func(cmd *cobra.Command, args []string) error { 106 | return updateCommand(*dryRun, *includePre, *upgradeMajor, os.Stdout, os.Executable) 107 | }, 108 | } 109 | 110 | dryRun = cmd.Flags().BoolP("dry-run", "n", false, "Only check if an update is available") 111 | includePre = cmd.Flags().BoolP("pre", "", false, "Upgrade to next pre-release version, if available") 112 | upgradeMajor = cmd.Flags().BoolP("major", "", false, "Upgrade major version, if available") 113 | 114 | return cmd 115 | } 116 | -------------------------------------------------------------------------------- /cmd/update/update_test.go: -------------------------------------------------------------------------------- 1 | package update 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "regexp" 9 | "runtime" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | 16 | "github.com/makkes/gitlab-cli/config" 17 | ) 18 | 19 | func TestUpdate(t *testing.T) { 20 | tests := map[string]struct { 21 | feed string 22 | currentVersion string 23 | updatedVersion string 24 | updatedBinary []byte 25 | dryRun bool 26 | upgradeMajor bool 27 | pre bool 28 | repo string 29 | out *regexp.Regexp 30 | outErr bool 31 | }{ 32 | "update happy path": { 33 | currentVersion: "3.6.2-55-ghf448b", 34 | updatedVersion: "3.6.3", 35 | updatedBinary: []byte("this is the updated gitlab binary"), 36 | out: regexp.MustCompile(`^Updating to v3.6.3\n`), 37 | }, 38 | "no update happy path": { 39 | currentVersion: "3.6.3", 40 | updatedBinary: []byte{}, 41 | out: regexp.MustCompile(`^You're already on the latest version `), 42 | }, 43 | "dry-run no update happy path": { 44 | currentVersion: "3.6.3", 45 | updatedBinary: []byte{}, 46 | dryRun: true, 47 | out: regexp.MustCompile(`^You're already on the latest version `), 48 | }, 49 | "dry-run update happy path": { 50 | currentVersion: "3.6.2-55-ghf448b", 51 | updatedVersion: "3.6.3", 52 | updatedBinary: []byte{}, 53 | dryRun: true, 54 | out: regexp.MustCompile( 55 | `^A new version is available: v3.6.3\nSee http://127.0.0.1:\d+/releases/v3.6.3 for details\n`), 56 | }, 57 | "unreachable repo": { 58 | repo: "http://doesntexist", 59 | currentVersion: "1.3.0", 60 | outErr: true, 61 | out: regexp.MustCompile(`^could not fetch latest version: `), 62 | updatedBinary: []byte{}, 63 | }, 64 | "dry-run update to pre-release": { 65 | currentVersion: "3.6.3", 66 | updatedVersion: "3.8.0-beta.1", 67 | updatedBinary: []byte{}, 68 | dryRun: true, 69 | pre: true, 70 | out: regexp.MustCompile( 71 | `^A new version is available: v3.8.0-beta.1\nSee http://127.0.0.1:\d+/releases/v3.8.0-beta.1 for details\n`), 72 | }, 73 | "update to pre-release": { 74 | currentVersion: "3.6.3", 75 | updatedVersion: "3.8.0-beta.1", 76 | updatedBinary: []byte("the updated binary"), 77 | dryRun: false, 78 | pre: true, 79 | out: regexp.MustCompile(`^Updating to v3.8.0-beta.1\n`), 80 | }, 81 | "no update of major version": { 82 | feed: "releases_test_major_upgrade.atom", 83 | currentVersion: "3.6.3", 84 | updatedVersion: "4.0.0", 85 | updatedBinary: []byte{}, 86 | dryRun: false, 87 | pre: false, 88 | out: regexp.MustCompile(`^You're already on the latest version `), 89 | }, 90 | "update of major version": { 91 | feed: "releases_test_major_upgrade.atom", 92 | currentVersion: "3.6.3", 93 | updatedVersion: "4.0.0", 94 | updatedBinary: []byte("4.0.0"), 95 | dryRun: false, 96 | pre: false, 97 | upgradeMajor: true, 98 | out: regexp.MustCompile(`^Updating to v4.0.0\n`), 99 | }, 100 | "update of major pre-release version": { 101 | feed: "releases_test_major_upgrade_pre.atom", 102 | currentVersion: "3.6.3", 103 | updatedVersion: "4.0.0-beta.1", 104 | updatedBinary: []byte("4.0.0 Beta.1"), 105 | dryRun: false, 106 | pre: true, 107 | upgradeMajor: true, 108 | out: regexp.MustCompile(`^Updating to v4.0.0-beta.1\n`), 109 | }, 110 | } 111 | 112 | for name, tc := range tests { 113 | t.Run(name, func(tt *testing.T) { 114 | feed := "releases_test.atom" 115 | if tc.feed != "" { 116 | feed = tc.feed 117 | } 118 | releasesFeed, err := ioutil.ReadFile(feed) 119 | require.NoError(t, err) 120 | 121 | outFile, err := ioutil.TempFile("", "gitlab-update-test") 122 | require.NoError(t, err) 123 | outFile.Close() 124 | 125 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 126 | switch r.RequestURI { 127 | case "/releases.atom": 128 | w.Write(releasesFeed) 129 | case fmt.Sprintf("/releases/download/v%s/gitlab_v%s_%s_%s", 130 | tc.updatedVersion, tc.updatedVersion, runtime.GOOS, runtime.GOARCH): 131 | w.Write(tc.updatedBinary) 132 | default: 133 | t.Errorf("Unexpected request for %s", r.RequestURI) 134 | } 135 | })) 136 | defer ts.Close() 137 | 138 | repo = ts.URL 139 | if tc.repo != "" { 140 | repo = tc.repo 141 | } 142 | config.Version = tc.currentVersion 143 | var out strings.Builder 144 | 145 | err = updateCommand(tc.dryRun, tc.pre, tc.upgradeMajor, &out, func() (string, error) { return outFile.Name(), nil }) 146 | 147 | if tc.outErr { 148 | require.Error(t, err) 149 | require.Regexp(t, tc.out, err.Error()) 150 | } else { 151 | require.NoError(t, err) 152 | assert.True(t, tc.out.MatchString(out.String()), 153 | "unexpected output: '%s' does not match '%s'", out.String(), tc.out.String()) 154 | } 155 | updatedContent, err := ioutil.ReadFile(outFile.Name()) 156 | require.NoError(t, err) 157 | assert.Equal(t, tc.updatedBinary, updatedContent) 158 | }) 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /cmd/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/makkes/gitlab-cli/config" 10 | ) 11 | 12 | func NewCommand() *cobra.Command { 13 | return &cobra.Command{ 14 | Use: "version", 15 | Short: "Display the version of GitLab CLI", 16 | RunE: func(cmd *cobra.Command, args []string) error { 17 | var version strings.Builder 18 | fmt.Fprintf(&version, "GitLab CLI %s", config.Version) 19 | fmt.Println(version.String()) 20 | return nil 21 | }, 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /config/cache.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | // Cache stores values indexed by a cache name and a cache key. 8 | type Cache interface { 9 | Flush() 10 | Put(cacheName, key, value string) 11 | Get(cacheName, key string) string 12 | } 13 | 14 | // MapCache is a Cache that stores all values in a map. 15 | type MapCache struct { 16 | data map[string]map[string]string 17 | } 18 | 19 | // NewCache creates a new empty Cache. 20 | func NewMapCache() *MapCache { 21 | return &MapCache{ 22 | data: make(map[string]map[string]string), 23 | } 24 | } 25 | 26 | // Put stores a value index by a cache name and a cache key. 27 | func (c *MapCache) Put(cacheName, key, v string) { 28 | if c.data[cacheName] == nil { 29 | c.data[cacheName] = make(map[string]string) 30 | } 31 | c.data[cacheName][key] = v 32 | } 33 | 34 | // Get returns the value pointed to by cacheName and key. 35 | func (c *MapCache) Get(cacheName, key string) string { 36 | return c.data[cacheName][key] 37 | } 38 | 39 | // Flush removes all values from the cache. 40 | func (c *MapCache) Flush() { 41 | c.data = make(map[string]map[string]string) 42 | } 43 | 44 | // MarshalJSON makes Cache implement json.Marshaler. 45 | func (c *MapCache) MarshalJSON() ([]byte, error) { 46 | return json.Marshal(c.data) 47 | } 48 | 49 | // UnmarshalJSON makes Cache implement json.Unmarshaler. 50 | func (c *MapCache) UnmarshalJSON(data []byte) error { 51 | aux := make(map[string]map[string]string) 52 | if err := json.Unmarshal(data, &aux); err != nil { 53 | return err 54 | } 55 | c.data = aux 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /config/cache_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestGetOnEmptyCache(t *testing.T) { 8 | c := NewMapCache() 9 | v := c.Get("does not", "exist") 10 | if v != "" { 11 | t.Errorf("Expected empty string and got %s", v) 12 | } 13 | } 14 | 15 | func TestPutAndGet(t *testing.T) { 16 | c := NewMapCache() 17 | c.Put("c1", "k1", "v1") 18 | v := c.Get("c1", "k1") 19 | if v != "v1" { 20 | t.Errorf("Expected '%s' to be 'v1'", v) 21 | } 22 | } 23 | func TestFlush(t *testing.T) { 24 | c := NewMapCache() 25 | c.Put("c1", "k1", "v1") 26 | c.Flush() 27 | if c.Get("c1", "k1") != "" { 28 | t.Error("Expected cache to be empty") 29 | } 30 | } 31 | func TestMarshalEmptyCache(t *testing.T) { 32 | c := NewMapCache() 33 | bytes, err := c.MarshalJSON() 34 | 35 | if err != nil { 36 | t.Errorf("Expected no error but got %s", err) 37 | } 38 | if string(bytes) != "{}" { 39 | t.Errorf("Expected empty JSON object but got %s", bytes) 40 | } 41 | } 42 | 43 | func TestMarshalPopulatedCache(t *testing.T) { 44 | c := NewMapCache() 45 | c.Put("c1", "k1", "v1") 46 | c.Put("c1", "k2", "v2") 47 | c.Put("c2", "k1", "v1") 48 | 49 | bytes, err := c.MarshalJSON() 50 | if err != nil { 51 | t.Errorf("Expected no error but got %s", err) 52 | } 53 | if string(bytes) != `{"c1":{"k1":"v1","k2":"v2"},"c2":{"k1":"v1"}}` { 54 | t.Errorf("Got unexpected JSON object: %s", bytes) 55 | } 56 | } 57 | func TestUnmarshalPopulatedCache(t *testing.T) { 58 | c := NewMapCache() 59 | 60 | err := c.UnmarshalJSON([]byte(`{"c1":{"k1":"v1","k2":"v2"},"c2":{"k1":"v1"}}`)) 61 | if err != nil { 62 | t.Errorf("Expected no error but got %s", err) 63 | } 64 | 65 | v := c.Get("c1", "k1") 66 | if v != "v1" { 67 | t.Errorf("Expected '%s' to be 'v1'", v) 68 | } 69 | v = c.Get("c1", "k2") 70 | if v != "v2" { 71 | t.Errorf("Expected '%s' to be 'v2'", v) 72 | } 73 | v = c.Get("c2", "k1") 74 | if v != "v1" { 75 | t.Errorf("Expected '%s' to be 'v1'", v) 76 | } 77 | } 78 | 79 | func TestUnmarshalBrokenJSON(t *testing.T) { 80 | c := NewMapCache() 81 | err := c.UnmarshalJSON([]byte(`broken`)) 82 | if err == nil { 83 | t.Errorf("Expected non-nil error") 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "os" 9 | "path" 10 | ) 11 | 12 | var configFile = ".gitlab-cli.json" 13 | var Version = "none" 14 | 15 | // keys for the configuration. 16 | const ( 17 | User = "user" 18 | Token = "token" 19 | URL = "url" 20 | ) 21 | 22 | type Config interface { 23 | Cache() Cache 24 | Write() 25 | Get(key string) string 26 | Set(key, value string) 27 | } 28 | 29 | type inMemoryConfig struct { 30 | version int 31 | cfg map[string]string 32 | cache *MapCache 33 | } 34 | 35 | func (c *inMemoryConfig) Get(key string) string { 36 | return c.cfg[key] 37 | } 38 | 39 | func (c *inMemoryConfig) Set(key, value string) { 40 | c.cfg[key] = value 41 | } 42 | 43 | func (c *inMemoryConfig) Cache() Cache { 44 | return c.cache 45 | } 46 | 47 | // MarshalJSON makes InMemoryConfig implement json.Marshaler. 48 | func (c *inMemoryConfig) MarshalJSON() ([]byte, error) { 49 | return json.Marshal(&struct { 50 | Version int 51 | Config map[string]string 52 | Cache *MapCache 53 | }{ 54 | Version: c.version, 55 | Config: c.cfg, 56 | Cache: c.cache, 57 | }) 58 | } 59 | 60 | // UnmarshalJSON makes Cache implement json.Unmarshaler. 61 | func (c *inMemoryConfig) UnmarshalJSON(data []byte) error { 62 | aux := &struct { 63 | Version int 64 | Config map[string]string 65 | Cache *MapCache 66 | }{} 67 | if err := json.Unmarshal(data, &aux); err != nil { 68 | return err 69 | } 70 | c.version = aux.Version 71 | c.cfg = aux.Config 72 | c.cache = aux.Cache 73 | return nil 74 | } 75 | 76 | var defaultConfig = inMemoryConfig{ 77 | version: 1, 78 | cfg: make(map[string]string), 79 | cache: &MapCache{ 80 | data: make(map[string]map[string]string), 81 | }, 82 | } 83 | 84 | func checkPermissions(f *os.File) { 85 | fi, err := f.Stat() 86 | if err != nil { 87 | fmt.Printf("Error checking file permissions: %s\n", err) 88 | } 89 | if fi.Mode() != 0600 { 90 | fmt.Printf("Correcting configuration file permissions from %#o to %#o\n", fi.Mode(), 0600) 91 | err = f.Chmod(0600) 92 | if err != nil { 93 | fmt.Printf("Error correcting configuration file permissions: %s\n", err) 94 | return 95 | } 96 | } 97 | } 98 | 99 | func Read() Config { 100 | f, err := os.Open(gitlabCLIConf()) 101 | if err != nil { 102 | if !os.IsNotExist(err) { 103 | fmt.Printf("WARNING: Error opening configuration file: %s\n\n", err) 104 | } 105 | return &defaultConfig 106 | } 107 | defer f.Close() 108 | 109 | checkPermissions(f) 110 | 111 | bytes, err := ioutil.ReadAll(f) 112 | if err != nil { 113 | fmt.Printf("WARNING: Error reading configuration: %s\n\n", err) 114 | return &defaultConfig 115 | } 116 | var config inMemoryConfig 117 | err = json.Unmarshal(bytes, &config) 118 | if err != nil { 119 | fmt.Printf("WARNING: Error reading configuration: %s\n\n", err) 120 | return &defaultConfig 121 | } 122 | 123 | writeConfigFile := false 124 | 125 | // We're presented with an old-style config file and need to updated its format 126 | if config.version == 0 { 127 | fmt.Fprintf(os.Stderr, "Converting legacy configuration file to new format\n") 128 | var oldConfig map[string]interface{} 129 | err = json.Unmarshal(bytes, &oldConfig) 130 | if err != nil { 131 | fmt.Fprintf(os.Stderr, "WARNING: Error reading legacy configuration file: %s\n", err) 132 | return &defaultConfig 133 | } 134 | config.cfg = map[string]string{ 135 | User: oldConfig["User"].(string), 136 | Token: oldConfig["Token"].(string), 137 | } 138 | if oldConfig["URL"] == nil { 139 | config.cfg[URL] = "https://gitlab.com" 140 | } else { 141 | config.cfg[URL] = oldConfig["URL"].(string) 142 | } 143 | config.version = 1 144 | writeConfigFile = true 145 | } 146 | 147 | if config.cache == nil { 148 | config.cache = NewMapCache() 149 | writeConfigFile = true 150 | } 151 | 152 | if writeConfigFile { 153 | config.Write() 154 | } 155 | 156 | return &config 157 | } 158 | 159 | func (c *inMemoryConfig) Write() { 160 | unindented, err := json.Marshal(c) 161 | if err != nil { 162 | fmt.Printf("Error writing configuration: %s\n", err) 163 | return 164 | } 165 | var indented bytes.Buffer 166 | if err = json.Indent(&indented, unindented, "", " "); err != nil { 167 | fmt.Printf("Error writing configuration: %s\n", err) 168 | } 169 | err = ioutil.WriteFile(gitlabCLIConf(), indented.Bytes(), 0600) 170 | if err != nil { 171 | fmt.Printf("Error writing configuration: %s\n", err) 172 | return 173 | } 174 | } 175 | 176 | func gitlabCLIConf() string { 177 | return path.Join(os.Getenv("HOME"), configFile) 178 | } 179 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/makkes/gitlab-cli 2 | 3 | require ( 4 | github.com/PuerkitoBio/goquery v1.5.0 // indirect 5 | github.com/blang/semver/v4 v4.0.0 6 | github.com/mmcdole/gofeed v1.0.0-beta2 7 | github.com/mmcdole/goxpp v0.0.0-20181012175147-0068e33feabf // indirect 8 | github.com/spf13/cobra v1.1.3 9 | github.com/stretchr/testify v1.4.0 10 | golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553 // indirect 11 | golang.org/x/term v0.7.0 12 | ) 13 | 14 | go 1.13 15 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 3 | cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= 4 | cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= 5 | cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= 6 | cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= 7 | cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= 8 | cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= 9 | cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= 10 | cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= 11 | cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= 12 | cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= 13 | dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= 14 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 15 | github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= 16 | github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= 17 | github.com/PuerkitoBio/goquery v1.5.0 h1:uGvmFXOA73IKluu/F84Xd1tt/z07GYm8X49XKHP7EJk= 18 | github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8kdmcsrnKqMg= 19 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 20 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 21 | github.com/andybalholm/cascadia v1.0.0 h1:hOCXnnZ5A+3eVDX8pvgl4kofXv2ELss0bKcqRySc45o= 22 | github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= 23 | github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= 24 | github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= 25 | github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= 26 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 27 | github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= 28 | github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= 29 | github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= 30 | github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= 31 | github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= 32 | github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= 33 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 34 | github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= 35 | github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= 36 | github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= 37 | github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 38 | github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= 39 | github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 40 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 41 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 42 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 43 | github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 44 | github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= 45 | github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= 46 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 47 | github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= 48 | github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= 49 | github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 50 | github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= 51 | github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= 52 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 53 | github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 54 | github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= 55 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 56 | github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 57 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 58 | github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 59 | github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= 60 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 61 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 62 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 63 | github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 64 | github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 65 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 66 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 67 | github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= 68 | github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 69 | github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 70 | github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 71 | github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= 72 | github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= 73 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 74 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 75 | github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= 76 | github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= 77 | github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= 78 | github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= 79 | github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= 80 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 81 | github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= 82 | github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= 83 | github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= 84 | github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= 85 | github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= 86 | github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= 87 | github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= 88 | github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= 89 | github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= 90 | github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= 91 | github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 92 | github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 93 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 94 | github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= 95 | github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= 96 | github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= 97 | github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= 98 | github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= 99 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 100 | github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= 101 | github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= 102 | github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= 103 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 104 | github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 105 | github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= 106 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 107 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 108 | github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= 109 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 110 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 111 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 112 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 113 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 114 | github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= 115 | github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= 116 | github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= 117 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 118 | github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= 119 | github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= 120 | github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 121 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 122 | github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= 123 | github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= 124 | github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= 125 | github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 126 | github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 127 | github.com/mmcdole/gofeed v1.0.0-beta2 h1:CjQ0ADhAwNSb08zknAkGOEYqr8zfZKfrzgk9BxpWP2E= 128 | github.com/mmcdole/gofeed v1.0.0-beta2/go.mod h1:/BF9JneEL2/flujm8XHoxUcghdTV6vvb3xx/vKyChFU= 129 | github.com/mmcdole/goxpp v0.0.0-20181012175147-0068e33feabf h1:sWGE2v+hO0Nd4yFU/S/mDBM5plIU8v/Qhfz41hkDIAI= 130 | github.com/mmcdole/goxpp v0.0.0-20181012175147-0068e33feabf/go.mod h1:pasqhqstspkosTneA62Nc+2p9SOBBYAPbnmRRWPQ0V8= 131 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 132 | github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 133 | github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 134 | github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= 135 | github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= 136 | github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= 137 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 138 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 139 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 140 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 141 | github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= 142 | github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 143 | github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= 144 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 145 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 146 | github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= 147 | github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= 148 | github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 149 | github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= 150 | github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= 151 | github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= 152 | github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 153 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 154 | github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= 155 | github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= 156 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 157 | github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 158 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= 159 | github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= 160 | github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= 161 | github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 162 | github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= 163 | github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= 164 | github.com/spf13/cobra v1.1.3 h1:xghbfqPkxzxP3C/f3n5DdpAbdKLj4ZE4BWQI362l53M= 165 | github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo= 166 | github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= 167 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 168 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 169 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 170 | github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= 171 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 172 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 173 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 174 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 175 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 176 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 177 | github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= 178 | github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= 179 | github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= 180 | go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= 181 | go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= 182 | go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= 183 | go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= 184 | go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= 185 | go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= 186 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 187 | golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 188 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 189 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 190 | golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 191 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 192 | golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 193 | golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= 194 | golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= 195 | golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= 196 | golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= 197 | golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 198 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 199 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 200 | golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 201 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 202 | golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 203 | golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 204 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 205 | golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= 206 | golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= 207 | golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 208 | golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= 209 | golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 210 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 211 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 212 | golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 213 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 214 | golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 215 | golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 216 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 217 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 218 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 219 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 220 | golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 221 | golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 222 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 223 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 224 | golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553 h1:efeOvDhwQ29Dj3SdAV/MJf8oukgn+8D8WgaCaRMchF8= 225 | golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 226 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 227 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 228 | golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 229 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 230 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 231 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 232 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 233 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 234 | golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 235 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 236 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 237 | golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 238 | golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 239 | golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 240 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 241 | golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 242 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 243 | golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 244 | golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 245 | golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 246 | golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 247 | golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= 248 | golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 249 | golang.org/x/term v0.7.0 h1:BEvjmm5fURWqcfbSKTdpkDXYBrUS1c0m8agp14W48vQ= 250 | golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= 251 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 252 | golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 253 | golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= 254 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 255 | golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 256 | golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 257 | golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 258 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 259 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 260 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 261 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 262 | golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 263 | golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 264 | golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 265 | golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 266 | golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 267 | golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 268 | golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 269 | golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 270 | golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 271 | golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 272 | golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 273 | golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 274 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 275 | google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= 276 | google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= 277 | google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= 278 | google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= 279 | google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= 280 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 281 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 282 | google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 283 | google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= 284 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 285 | google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 286 | google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 287 | google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 288 | google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 289 | google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 290 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 291 | google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= 292 | google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 293 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 294 | google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= 295 | google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= 296 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 297 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 298 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 299 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 300 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 301 | gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 302 | gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= 303 | gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= 304 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 305 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 306 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 307 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 308 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 309 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 310 | honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 311 | honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 312 | honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 313 | rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= 314 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | is_command() { 4 | command -v "$1" >/dev/null 5 | } 6 | 7 | echoerr() { 8 | echo >&2 "$@" 9 | } 10 | 11 | check_prereqs() { 12 | if ! is_command curl; then 13 | echoerr "curl is needed for this script to work" 14 | exit 1 15 | fi 16 | if ! is_command grep; then 17 | echoerr "grep is needed for this script to work" 18 | exit 1 19 | fi 20 | if ! is_command cut; then 21 | echoerr "cut is needed for this script to work" 22 | exit 1 23 | fi 24 | if ! is_command sed; then 25 | echoerr "sed is needed for this script to work" 26 | exit 1 27 | fi 28 | } 29 | 30 | do_install() { 31 | set -e 32 | check_prereqs 33 | target_dir="/usr/local/bin" 34 | while getopts "b:" arg; do 35 | case "$arg" in 36 | b) 37 | target_dir="$OPTARG" 38 | ;; 39 | *) 40 | exit 1 41 | ;; 42 | esac 43 | done 44 | shift $((OPTIND - 1)) 45 | 46 | version=${1:-} 47 | [ -z "$version" ] && version="latest" 48 | 49 | set +e 50 | release_json=$(curl -fsLH 'Accept: application/json' https://github.com/makkes/gitlab-cli/releases/${version}) 51 | set -e 52 | [ -z "$release_json" ] && echoerr "Unknown version ${version}" && exit 1 53 | release_tag=$(echo "$release_json" | grep -o '"tag_name":"[^"]*"' | cut -d":" -f2 | sed s/\"//g) 54 | 55 | kernel_name=$(uname -s) 56 | machine=$(uname -m) 57 | case "${kernel_name}" in 58 | Darwin) 59 | os="darwin" 60 | ;; 61 | Linux) 62 | os="linux" 63 | ;; 64 | *) 65 | echoerr "Unsupported OS ${kernel_name}" && exit 1 66 | ;; 67 | esac 68 | [ "${machine}" != "x86_64" ] && echoerr "Unsupported CPU architecture ${machine}" && exit 1 69 | arch="amd64" 70 | 71 | download_url="https://github.com/makkes/gitlab-cli/releases/download/${release_tag}/gitlab_${release_tag}_${os}_${arch}" 72 | tmpdir=$(mktemp -d) 73 | echo "Downloading gitlab ${release_tag}..." 74 | set +e 75 | if ! curl --progress-bar -fLo "${tmpdir}"/gitlab "${download_url}"; then 76 | echoerr "Error downloading from ${download_url}" 77 | exit 1 78 | fi 79 | set -e 80 | install "${tmpdir}/gitlab" "${target_dir}" 81 | 82 | echo "Installed gitlab ${release_tag} into ${target_dir}" 83 | } 84 | 85 | (do_install "$@") 86 | -------------------------------------------------------------------------------- /mock/mock.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/makkes/gitlab-cli/api" 7 | "github.com/makkes/gitlab-cli/config" 8 | ) 9 | 10 | type Client struct { 11 | Res []byte 12 | Status int 13 | Err error 14 | } 15 | 16 | var _ api.Client = Client{} 17 | 18 | func (m Client) Get(path string) ([]byte, int, error) { 19 | return m.Res, m.Status, m.Err 20 | } 21 | 22 | func (m Client) CreateAccessToken(projectID int, name string, expires time.Time, scopes []string) (api.ProjectAccessToken, error) { 23 | return api.ProjectAccessToken{}, nil 24 | } 25 | 26 | func (m Client) GetAccessTokens(pid string) ([]api.ProjectAccessToken, error) { 27 | return nil, nil 28 | } 29 | 30 | func (m Client) Post(path string, body interface{}) ([]byte, int, error) { 31 | return m.Res, 0, m.Err 32 | } 33 | 34 | func (m Client) Delete(path string) (int, error) { 35 | return 0, m.Err 36 | } 37 | 38 | func (m Client) FindProject(nameOrID string) (*api.Project, error) { 39 | return nil, nil 40 | } 41 | 42 | func (m Client) FindProjectDetails(nameOrID string) ([]byte, error) { 43 | return nil, nil 44 | } 45 | 46 | func (m Client) Login(token, url string) (string, error) { 47 | return "", nil 48 | } 49 | 50 | func (m Client) GetPipelineDetails(projectID, pipelineID string) ([]byte, error) { 51 | return nil, nil 52 | } 53 | 54 | type Cache struct { 55 | Calls [][]string 56 | Cache map[string]map[string]string 57 | } 58 | 59 | func (c Cache) Flush() {} 60 | 61 | func (c Cache) Get(cacheName, key string) string { 62 | return "" 63 | } 64 | 65 | func (c *Cache) Put(cacheName, key, value string) { 66 | if c.Calls == nil { 67 | c.Calls = make([][]string, 0) 68 | } 69 | c.Calls = append(c.Calls, []string{cacheName, key, value}) 70 | } 71 | 72 | type Config struct { 73 | CacheData config.Cache 74 | Cfg map[string]string 75 | WriteCalls int 76 | } 77 | 78 | func (c Config) Cache() config.Cache { 79 | return c.CacheData 80 | } 81 | 82 | func (c *Config) Write() { 83 | c.WriteCalls++ 84 | } 85 | 86 | func (c Config) Get(key string) string { 87 | return c.Cfg[key] 88 | } 89 | 90 | func (c Config) Set(key, value string) {} 91 | -------------------------------------------------------------------------------- /support/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | go test ./... 4 | -------------------------------------------------------------------------------- /table/table.go: -------------------------------------------------------------------------------- 1 | package table 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | "github.com/makkes/gitlab-cli/api" 11 | ) 12 | 13 | func pad(s string, width int) string { 14 | if width < 0 { 15 | return s 16 | } 17 | return fmt.Sprintf(fmt.Sprintf("%%-%ds", width), s) 18 | } 19 | 20 | func calcProjectColumnWidths(ps []api.Project) map[string]int { 21 | res := make(map[string]int) 22 | res["id"] = 15 23 | res["name"] = 40 24 | res["url"] = 50 25 | res["clone"] = 50 26 | for _, p := range ps { 27 | w := len(strconv.Itoa(p.ID)) 28 | if w > res["id"] { 29 | res["id"] = w 30 | } 31 | 32 | w = len(p.Name) 33 | if w > res["name"] { 34 | res["name"] = w 35 | } 36 | 37 | w = len(p.URL) 38 | if w > res["url"] { 39 | res["url"] = w 40 | } 41 | 42 | w = len(p.SSHGitURL) 43 | if w > res["clone"] { 44 | res["clone"] = w 45 | } 46 | } 47 | return res 48 | } 49 | 50 | func calcJobsColumnWidths() map[string]int { 51 | res := make(map[string]int) 52 | res["id"] = 20 53 | res["status"] = 20 54 | res["stage"] = 10 55 | return res 56 | } 57 | 58 | func calcPipelineColumnWidths(pipelines []api.PipelineDetails, now time.Time) map[string]int { 59 | res := make(map[string]int) 60 | res["id"] = 20 61 | res["status"] = 20 62 | res["duration"] = 10 63 | res["started_at"] = 25 64 | res["url"] = 50 65 | for _, p := range pipelines { 66 | w := len(fmt.Sprintf("%d:%d", p.ProjectID, p.ID)) 67 | if w > res["id"] { 68 | res["id"] = w 69 | } 70 | 71 | w = len(p.Status) 72 | if w > res["status"] { 73 | res["status"] = w 74 | } 75 | 76 | w = len(p.Duration(now)) 77 | if w > res["duration"] { 78 | res["duration"] = w 79 | } 80 | 81 | w = len(p.URL) 82 | if w > res["url"] { 83 | res["url"] = w 84 | } 85 | } 86 | return res 87 | } 88 | 89 | func calcIssueColumnWidths(issues []api.Issue) map[string]int { 90 | res := make(map[string]int) 91 | res["id"] = 20 92 | res["title"] = 30 93 | res["state"] = 10 94 | res["url"] = 50 95 | 96 | for _, i := range issues { 97 | w := len(fmt.Sprintf("%d:%d", i.ProjectID, i.ID)) 98 | if w > res["id"] { 99 | res["id"] = w 100 | } 101 | 102 | w = len(i.State) 103 | if w > res["state"] { 104 | res["state"] = w 105 | } 106 | 107 | w = len(i.URL) 108 | if w > res["url"] { 109 | res["url"] = w 110 | } 111 | } 112 | return res 113 | } 114 | 115 | func calcVarColumnWidths(vars []api.Var) map[string]int { 116 | res := make(map[string]int) 117 | res["key"] = 20 118 | res["value"] = 40 119 | res["protected"] = 9 120 | res["environment_scope"] = 11 121 | 122 | for _, v := range vars { 123 | w := len(v.Key) 124 | if w > res["key"] { 125 | res["key"] = w 126 | } 127 | 128 | w = len(v.Value) 129 | if w > res["value"] { 130 | res["value"] = w 131 | } 132 | 133 | w = len(v.EnvironmentScope) 134 | if w > res["environment_scope"] { 135 | res["environment_scope"] = w 136 | } 137 | } 138 | return res 139 | } 140 | 141 | func PrintJobs(jobs api.Jobs) { 142 | widths := calcJobsColumnWidths() 143 | fmt.Printf("%s %s %s\n", 144 | pad("ID", widths["id"]), 145 | pad("STATUS", widths["status"]), 146 | pad("STAGE", widths["stage"])) 147 | for _, j := range jobs { 148 | fmt.Printf("%s %s %s\n", 149 | pad(fmt.Sprintf("%d:%d", j.ProjectID, j.ID), widths["id"]), 150 | pad(j.Status, widths["status"]), 151 | pad(j.Stage, widths["stage"])) 152 | } 153 | } 154 | 155 | func PrintPipelines(ps []api.PipelineDetails) { 156 | widths := calcPipelineColumnWidths(ps, time.Now()) 157 | fmt.Printf("%s %s %s %s %s\n", 158 | pad("ID", widths["id"]), 159 | pad("STATUS", widths["status"]), 160 | pad("DURATION", widths["duration"]), 161 | pad("STARTED AT", widths["started_at"]), 162 | pad("URL", widths["url"])) 163 | for _, p := range ps { 164 | fmt.Printf("%s %s %s %s %s\n", 165 | pad(fmt.Sprintf("%d:%d", p.ProjectID, p.ID), widths["id"]), 166 | pad(p.Status, widths["status"]), 167 | pad(p.Duration(time.Now()), widths["duration"]), 168 | pad(p.StartedAt.Format("2006-01-02 15:04:05 MST"), widths["started_at"]), 169 | pad(p.URL, widths["url"])) 170 | } 171 | } 172 | 173 | func PrintProjects(out io.Writer, ps []api.Project) { 174 | widths := calcProjectColumnWidths(ps) 175 | fmt.Fprintf(out, "%s %s %s %s\n", 176 | pad("ID", widths["id"]), 177 | pad("NAME", widths["name"]), 178 | pad("URL", widths["url"]), 179 | pad("CLONE", widths["clone"])) 180 | for _, p := range ps { 181 | fmt.Fprintf(out, "%s %s %s %s\n", 182 | pad(strconv.Itoa(p.ID), widths["id"]), 183 | pad(p.Name, widths["name"]), 184 | pad(p.URL, widths["url"]), 185 | pad(p.SSHGitURL, widths["clone"])) 186 | } 187 | } 188 | 189 | func calcProjectAccessTokenColumnWidths(atl []api.ProjectAccessToken) map[string]int { 190 | res := make(map[string]int) 191 | res["id"] = 10 192 | res["name"] = 20 193 | res["expires"] = 15 194 | res["scopes"] = 5 195 | 196 | for _, t := range atl { 197 | w := len(fmt.Sprintf("%d", t.ID)) 198 | if w > res["id"] { 199 | res["id"] = w 200 | } 201 | 202 | w = len(t.Name) 203 | if w > res["name"] { 204 | res["name"] = w 205 | } 206 | 207 | w = len(t.ExpiresAt.Format(time.Stamp)) 208 | if w > res["expires"] { 209 | res["expires"] = w 210 | } 211 | 212 | w = len(strings.Join(t.Scopes, ",")) 213 | if w > res["scopes"] { 214 | res["scopes"] = w 215 | } 216 | } 217 | return res 218 | } 219 | 220 | func PrintProjectAccessTokens(out io.Writer, atl []api.ProjectAccessToken) { 221 | widths := calcProjectAccessTokenColumnWidths(atl) 222 | fmt.Fprintf(out, "%s %s %s %s\n", 223 | pad("ID", widths["id"]), 224 | pad("NAME", widths["name"]), 225 | pad("EXPIRES AT", widths["expires"]), 226 | pad("SCOPES", widths["scopes"]), 227 | ) 228 | 229 | for _, t := range atl { 230 | name := t.Name 231 | if len(name) > widths["name"] { 232 | name = name[0:widths["name"]-1] + "…" 233 | } 234 | fmt.Fprintf(out, "%s %s %s %s\n", 235 | pad(fmt.Sprintf("%d", t.ID), widths["id"]), 236 | pad(name, widths["name"]), 237 | pad(t.ExpiresAt.Format(time.Stamp), widths["expires"]), 238 | pad(strings.Join(t.Scopes, ","), widths["scopes"]), 239 | ) 240 | } 241 | } 242 | 243 | func PrintIssues(out io.Writer, issues []api.Issue) { 244 | widths := calcIssueColumnWidths(issues) 245 | fmt.Fprintf(out, "%s %s %s %s\n", 246 | pad("ID", widths["id"]), 247 | pad("TITLE", widths["title"]), 248 | pad("STATE", widths["state"]), 249 | pad("URL", widths["url"])) 250 | for _, i := range issues { 251 | title := i.Title 252 | if len(title) > widths["title"] { 253 | title = title[0:widths["title"]-1] + "…" 254 | } 255 | fmt.Fprintf(out, "%s %s %s %s\n", 256 | pad(fmt.Sprintf("%d:%d", i.ProjectID, i.ID), widths["id"]), 257 | pad(title, widths["title"]), 258 | pad(i.State, widths["state"]), 259 | pad(i.URL, widths["url"])) 260 | } 261 | } 262 | 263 | func PrintVars(out io.Writer, vars []api.Var) { 264 | widths := calcVarColumnWidths(vars) 265 | fmt.Fprintf(out, "%s %s %s %s\n", 266 | pad("KEY", widths["key"]), 267 | pad("VALUE", widths["value"]), 268 | pad("PROTECTED", widths["protected"]), 269 | pad("ENVIRONMENT", widths["environment_scope"])) 270 | for _, v := range vars { 271 | fmt.Fprintf(out, "%s %s %s %s\n", 272 | pad(v.Key, widths["key"]), 273 | pad(v.Value, widths["value"]), 274 | pad(fmt.Sprintf("%t", v.Protected), widths["protected"]), 275 | pad(v.EnvironmentScope, widths["environment_scope"])) 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /table/table_test.go: -------------------------------------------------------------------------------- 1 | package table 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | "time" 8 | 9 | "github.com/makkes/gitlab-cli/api" 10 | ) 11 | 12 | func checkColumn(t *testing.T, cols map[string]int, col string, width int) { 13 | if cols[col] != width { 14 | t.Errorf("'%s' column has unexpected width %d", col, cols[col]) 15 | } 16 | } 17 | 18 | func TestPipelineColumnWidths(t *testing.T) { 19 | var pipelineColumnWidthTests = []struct { 20 | name string 21 | in []api.PipelineDetails 22 | out map[string]int 23 | }{ 24 | { 25 | "empty input", 26 | []api.PipelineDetails{}, 27 | map[string]int{ 28 | "id": 20, 29 | "status": 20, 30 | "duration": 10, 31 | "url": 50, 32 | }, 33 | }, 34 | { 35 | "nil input", 36 | nil, 37 | map[string]int{ 38 | "id": 20, 39 | "status": 20, 40 | "duration": 10, 41 | "url": 50, 42 | }, 43 | }, 44 | { 45 | "happy path", 46 | []api.PipelineDetails{ 47 | { 48 | ID: 99, 49 | Status: "this is a status with more than 20 characters", 50 | URL: "This is a uniform resource locator with more than 50 characters", 51 | RecordedDuration: func() *int { i := int(50); return &i }(), 52 | }, 53 | }, 54 | map[string]int{ 55 | "id": 20, 56 | "status": 45, 57 | "url": 63, 58 | "duration": 10, 59 | }, 60 | }, 61 | } 62 | 63 | for _, tt := range pipelineColumnWidthTests { 64 | t.Run(tt.name, func(t *testing.T) { 65 | res := calcPipelineColumnWidths(tt.in, time.Now()) 66 | for k, v := range tt.out { 67 | checkColumn(t, res, k, v) 68 | } 69 | }) 70 | } 71 | } 72 | 73 | func TestProjectColumnWidths(t *testing.T) { 74 | var projectColumnWidthTests = []struct { 75 | name string 76 | in []api.Project 77 | out map[string]int 78 | }{ 79 | { 80 | "empty input", 81 | []api.Project{}, 82 | map[string]int{ 83 | "id": 15, 84 | "name": 40, 85 | "url": 50, 86 | "clone": 50, 87 | }, 88 | }, 89 | { 90 | "nil input", 91 | nil, 92 | map[string]int{ 93 | "id": 15, 94 | "name": 40, 95 | "url": 50, 96 | "clone": 50, 97 | }, 98 | }, 99 | { 100 | "happy path", 101 | []api.Project{ 102 | { 103 | ID: 99, 104 | Name: "this is a name with more than 40 characters", 105 | URL: "This is a uniform resource locator with more than 50 characters", 106 | }, 107 | }, 108 | map[string]int{ 109 | "id": 15, 110 | "name": 43, 111 | "url": 63, 112 | "clone": 50, 113 | }, 114 | }, 115 | } 116 | 117 | for _, tt := range projectColumnWidthTests { 118 | t.Run(tt.name, func(t *testing.T) { 119 | res := calcProjectColumnWidths(tt.in) 120 | if len(res) != len(tt.out) { 121 | t.Errorf("Expected map with %d entries but got %d", len(tt.out), len(res)) 122 | } 123 | for k, v := range tt.out { 124 | checkColumn(t, res, k, v) 125 | } 126 | }) 127 | } 128 | } 129 | 130 | func TestIssuesTable(t *testing.T) { 131 | var issuesTableTests = []struct { 132 | name string 133 | writer *strings.Builder 134 | issues []api.Issue 135 | out string 136 | }{ 137 | { 138 | "empty input", 139 | &strings.Builder{}, 140 | []api.Issue{}, 141 | "ID TITLE STATE URL " + 142 | " \n", 143 | }, 144 | { 145 | "nil input", 146 | &strings.Builder{}, 147 | nil, 148 | "ID TITLE STATE URL " + 149 | " \n", 150 | }, 151 | { 152 | "happy path", 153 | &strings.Builder{}, 154 | []api.Issue{ 155 | { 156 | ID: 99, 157 | Title: "this is a name with more than 40 characters", 158 | State: "this is a loong state", 159 | URL: "This is a uniform resource locator with more than 50 characters", 160 | }, 161 | }, 162 | `ID TITLE STATE URL ` + 163 | ` 164 | 0:99 this is a name with more than… this is a loong state This is a uniform resource locator with ` + 165 | `more than 50 characters 166 | `, 167 | }, 168 | } 169 | 170 | for _, tt := range issuesTableTests { 171 | t.Run(tt.name, func(t *testing.T) { 172 | PrintIssues(tt.writer, tt.issues) 173 | if tt.writer.String() != tt.out { 174 | t.Errorf("Unexpected result: '%s'", tt.writer.String()) 175 | } 176 | }) 177 | } 178 | } 179 | 180 | func TestVarsTable(t *testing.T) { 181 | varsTableTests := []struct { 182 | name string 183 | writer *strings.Builder 184 | vars []api.Var 185 | out string 186 | }{ 187 | { 188 | "empty input", 189 | &strings.Builder{}, 190 | []api.Var{}, 191 | "KEY VALUE PROTECTED ENVIRONMENT\n", 192 | }, 193 | { 194 | "nil input", 195 | &strings.Builder{}, 196 | nil, 197 | "KEY VALUE PROTECTED ENVIRONMENT\n", 198 | }, 199 | { 200 | "happy path", 201 | &strings.Builder{}, 202 | []api.Var{ 203 | { 204 | Key: "key 1", 205 | Value: "value 1", 206 | Protected: false, 207 | EnvironmentScope: "test", 208 | }, 209 | { 210 | Key: "", 211 | Value: "", 212 | Protected: true, 213 | EnvironmentScope: "test", 214 | }, 215 | { 216 | Key: "", 217 | Value: "some value", 218 | Protected: false, 219 | EnvironmentScope: "test", 220 | }, 221 | { 222 | Key: "some key", 223 | Value: "", 224 | Protected: false, 225 | EnvironmentScope: "test", 226 | }, 227 | }, 228 | `KEY VALUE PROTECTED ENVIRONMENT 229 | key 1 value 1 false test 230 | true test 231 | some value false test 232 | some key false test 233 | `, 234 | }, 235 | } 236 | 237 | for _, tt := range varsTableTests { 238 | t.Run(tt.name, func(t *testing.T) { 239 | PrintVars(tt.writer, tt.vars) 240 | if tt.writer.String() != tt.out { 241 | t.Errorf("Unexpected result: '%s'", tt.writer.String()) 242 | t.Errorf("Expected result: '%s'", tt.out) 243 | } 244 | }) 245 | } 246 | } 247 | 248 | func TestPad(t *testing.T) { 249 | var padTable = []struct { 250 | s string 251 | w int 252 | out string 253 | }{ 254 | {"", 0, ""}, 255 | {"", 10, " "}, 256 | {"don't shorten me", 0, "don't shorten me"}, 257 | {"i'm too short", 20, "i'm too short "}, 258 | {"not padded when negative", -100, "not padded when negative"}, 259 | } 260 | 261 | for _, tt := range padTable { 262 | t.Run(fmt.Sprintf("'%s':%d", tt.s, tt.w), func(t *testing.T) { 263 | res := pad(tt.s, tt.w) 264 | if res != tt.out { 265 | t.Errorf("Expected '%s' to be '%s'", res, tt.out) 266 | } 267 | }) 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /versions/versions.go: -------------------------------------------------------------------------------- 1 | package versions 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/blang/semver/v4" 8 | "github.com/mmcdole/gofeed" 9 | ) 10 | 11 | func LatestVersion(repo string, currentVersion semver.Version, upgradeMajor, includePreReleases bool) (string, error) { 12 | fp := gofeed.NewParser() 13 | feed, err := fp.ParseURL(repo + "/releases.atom") 14 | if err != nil { 15 | return "", err 16 | } 17 | if len(feed.Items) == 0 { 18 | return "", fmt.Errorf("no entry in releases feed") 19 | } 20 | 21 | for _, item := range feed.Items { 22 | IDElements := strings.Split(item.GUID, "/") 23 | if len(IDElements) < 3 { 24 | continue // entry is malformed, skip it 25 | } 26 | v, err := semver.ParseTolerant(IDElements[2]) 27 | if err != nil { 28 | continue // this is not a semver, skip it 29 | } 30 | if v.Pre != nil && !includePreReleases { 31 | continue // this is a pre-release, skip it 32 | } 33 | if v.Major > currentVersion.Major && !upgradeMajor { 34 | continue // this is a new major version, skip ip 35 | } 36 | return fmt.Sprintf("v%s", v.String()), nil 37 | } 38 | return "", fmt.Errorf("no parseable version found in releases feed") 39 | } 40 | --------------------------------------------------------------------------------