├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ ├── depreview.yml │ ├── lint.yml │ ├── security.yml │ └── test.yml ├── .gitignore ├── .golangci.yml ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── go.mod ├── go.sum ├── internal ├── app.go ├── commit.go ├── commit_test.go ├── gitlab.go ├── gitlab_test.go └── testutil │ └── log.go ├── main.go ├── main_test.go ├── screenshots ├── contribs_after_dark.png ├── contribs_after_light.png ├── contribs_before_dark.png └── contribs_before_light.png └── tools ├── Makefile ├── go.mod ├── go.sum └── pinversion.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | - package-ecosystem: gomod 8 | directory: "/tools" 9 | schedule: 10 | interval: weekly 11 | - package-ecosystem: github-actions 12 | directory: "/" 13 | schedule: 14 | interval: weekly 15 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | schedule: 9 | - cron: '0 8 * * 1' # run "At 8:00 on Monday" 10 | 11 | jobs: 12 | run: 13 | runs-on: ubuntu-latest 14 | timeout-minutes: 5 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - uses: actions/setup-go@v5 20 | with: 21 | go-version: 'stable' 22 | check-latest: true 23 | 24 | - run: go mod tidy && git diff --exit-code 25 | 26 | - run: go mod download 27 | 28 | - run: go mod verify 29 | 30 | - run: go build -o /dev/null ./... 31 | -------------------------------------------------------------------------------- /.github/workflows/depreview.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement 2 | name: Dependency Review 3 | 4 | on: 5 | pull_request: 6 | branches: [main] 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | dependency-review: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - uses: actions/dependency-review-action@v4 18 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | env: 10 | MAKEFLAGS: --no-print-directory 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 5 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - uses: actions/setup-go@v5 21 | with: 22 | go-version: 'stable' 23 | check-latest: true 24 | 25 | - id: golangci-lint-version 26 | run: | 27 | make gh-lint-version >> $GITHUB_OUTPUT 28 | 29 | - uses: golangci/golangci-lint-action@v7 30 | with: 31 | version: ${{ steps.golangci-lint-version.outputs.GOLANGCI_LINT_VERSION }} 32 | args: --verbose 33 | -------------------------------------------------------------------------------- /.github/workflows/security.yml: -------------------------------------------------------------------------------- 1 | name: Security Scan 2 | 3 | on: 4 | schedule: 5 | # Every Monday at 1PM UTC 6 | - cron: "0 13 * * 1" 7 | push: 8 | branches: [main] 9 | pull_request: 10 | branches: [main] 11 | 12 | jobs: 13 | vulncheck: 14 | name: Scan for vulnerabilities in Go code 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: govulncheck 18 | uses: golang/govulncheck-action@v1 19 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 5 13 | env: 14 | # Settings - Secrets and variables - Actions - New repository secret GITLAB_TOKEN 15 | # Settings - Secrets and variables - Dependabot - New repository secret GITLAB_TOKEN 16 | GITLAB_TOKEN: ${{ secrets.GITLAB_TOKEN }} 17 | GITLAB_BASE_URL: https://gitlab.com 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - uses: actions/setup-go@v5 22 | with: 23 | go-version: 'stable' 24 | check-latest: true 25 | 26 | - run: go test -v -count=1 -race -shuffle=on -cover ./... 27 | 28 | - run: go test -tags=integration -run=TestGitLab -shuffle=on -count=1 -race -v ./... 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | bin/ 3 | *.db 4 | repo\.* 5 | \.vscode 6 | \.env 7 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | run: 3 | issues-exit-code: 1 4 | tests: true 5 | linters: 6 | default: all 7 | disable: 8 | - depguard 9 | - err113 10 | - exhaustruct 11 | - mnd 12 | - nonamedreturns 13 | - paralleltest 14 | - rowserrcheck 15 | - wastedassign 16 | settings: 17 | decorder: 18 | dec-order: 19 | - const 20 | - type 21 | - var 22 | - func 23 | disable-dec-order-check: false 24 | disable-init-func-first-check: false 25 | gocognit: 26 | min-complexity: 15 27 | goconst: 28 | min-len: 2 29 | min-occurrences: 2 30 | gocritic: 31 | enable-all: true 32 | gocyclo: 33 | min-complexity: 10 34 | godot: 35 | scope: all 36 | capital: true 37 | govet: 38 | disable: 39 | - fieldalignment 40 | enable-all: true 41 | lll: 42 | line-length: 140 43 | misspell: 44 | locale: US 45 | revive: 46 | # Set below 0.8 to enable error-strings 47 | confidence: 0.6 48 | rules: 49 | - name: add-constant 50 | disabled: true 51 | - name: argument-limit 52 | - name: atomic 53 | - name: banned-characters 54 | - name: bare-return 55 | - name: blank-imports 56 | - name: bool-literal-in-expr 57 | - name: call-to-gc 58 | - name: comment-spacings 59 | - name: confusing-naming 60 | - name: confusing-results 61 | - name: constant-logical-expr 62 | - name: context-as-argument 63 | arguments: 64 | # allow functions with signature: func foo(t *testing.T, ctx context.Context) 65 | - allowTypesBefore: "*testing.T" 66 | - name: context-keys-type 67 | - name: datarace 68 | - name: deep-exit 69 | - name: defer 70 | - name: dot-imports 71 | - name: duplicated-imports 72 | - name: early-return 73 | - name: empty-block 74 | - name: empty-lines 75 | - name: error-naming 76 | - name: error-return 77 | - name: error-strings 78 | - name: errorf 79 | - name: enforce-map-style 80 | - name: enforce-repeated-arg-type-style 81 | - name: enforce-slice-style 82 | - name: exported 83 | - name: file-header 84 | - name: file-length-limit 85 | - name: filename-format 86 | arguments: 87 | - "^[_a-z][_a-z0-9]*.go$" 88 | - name: flag-parameter 89 | - name: function-length 90 | - name: function-result-limit 91 | - name: get-return 92 | - name: identical-branches 93 | - name: if-return 94 | - name: import-alias-naming 95 | - name: import-shadowing 96 | - name: imports-blocklist 97 | - name: increment-decrement 98 | - name: indent-error-flow 99 | - name: line-length-limit 100 | arguments: 101 | - 120 102 | - name: max-control-nesting 103 | - name: max-public-structs 104 | - name: modifies-parameter 105 | - name: modifies-value-receiver 106 | - name: nested-structs 107 | - name: optimize-operands-order 108 | - name: package-comments 109 | - name: range-val-address 110 | - name: range-val-in-closure 111 | - name: range 112 | - name: receiver-naming 113 | - name: redefines-builtin-id 114 | - name: redundant-build-tag 115 | - name: redundant-import-alias 116 | - name: string-format 117 | - name: string-of-int 118 | - name: struct-tag 119 | arguments: 120 | - "json,inline" 121 | - name: superfluous-else 122 | - name: time-equal 123 | - name: time-naming 124 | - name: unchecked-type-assertion 125 | - name: unconditional-recursion 126 | - name: unexported-naming 127 | - name: unexported-return 128 | - name: unhandled-error 129 | - name: unnecessary-stmt 130 | - name: unreachable-code 131 | - name: unused-parameter 132 | - name: unused-receiver 133 | - name: use-any 134 | - name: use-errors-new 135 | - name: useless-break 136 | - name: var-declaration 137 | - name: var-naming 138 | - name: waitgroup-by-value 139 | usetesting: 140 | context-background: true 141 | context-todo: true 142 | os-chdir: true 143 | os-mkdir-temp: true 144 | os-setenv: true 145 | os-temp-dir: true 146 | os-create-temp: true 147 | exclusions: 148 | generated: lax 149 | presets: 150 | - comments 151 | - common-false-positives 152 | - legacy 153 | - std-error-handling 154 | paths: 155 | - third_party$ 156 | - builtin$ 157 | - examples$ 158 | formatters: 159 | enable: 160 | - gci 161 | - gofmt 162 | - gofumpt 163 | - goimports 164 | settings: 165 | gci: 166 | sections: 167 | - standard 168 | - default 169 | - prefix(github.com/alexandear/import-gitlab-commits) 170 | gofumpt: 171 | extra-rules: true 172 | exclusions: 173 | generated: lax 174 | paths: 175 | - third_party$ 176 | - builtin$ 177 | - examples$ 178 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to import-gitlab-commits 2 | 3 | First off, thanks for taking the time to contribute! ❤️ 4 | 5 | All types of contributions are encouraged and valued. 6 | 7 | > And if you like the project, but just don't have time to contribute, that's fine. 8 | > There are other easy ways to support the project and show your appreciation, 9 | > which we would also be very happy about: 10 | > - Star the project 11 | > - Refer this project in your project's readme 12 | > - Mention the project at local meetups and tell your friends/colleagues 13 | > - Share it 14 | 15 | ### Pull Requests 16 | 17 | 1. Fork the repository. 18 | 2. Install or update [Go](https://go.dev/dl), at the version specified in [`go.mod`](https://github.com/alexandear/import-gitlab-commits/blob/main/go.mod#L3). 19 | 3. Create a working branch and start with your changes. 20 | 4. Commit the changes once you are happy with them. 21 | 5. When you're finished with the changes, create a pull request. 22 | 23 | ### Issues 24 | 25 | If you spot a problem, [search if an issue already exists](https://docs.github.com/en/github/searching-for-information-on-github/searching-on-github/searching-issues-and-pull-requests#search-by-the-title-body-or-comments). 26 | If a related issue doesn't exist, you can open a new issue using a relevant [issue form](https://github.com/alexandear/import-gitlab-commits/issues/new/choose). 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Oleksandr Redko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | MAKEFILE_PATH := $(abspath $(dir $(abspath $(lastword $(MAKEFILE_LIST))))) 2 | PATH := $(MAKEFILE_PATH):$(PATH) 3 | 4 | export GOBIN := $(MAKEFILE_PATH)/bin 5 | 6 | PATH := $(GOBIN):$(PATH) 7 | 8 | GOLANGCI_LINT_VERSION ?= $(shell $(MAKE) -C tools golangci-lint-version) 9 | 10 | .PHONY: all 11 | all: clean format build lint test 12 | 13 | .PHONY: clean 14 | clean: 15 | @echo clean 16 | @go clean 17 | 18 | .PHONY: build 19 | build: 20 | @echo build 21 | @go build -o $(GOBIN)/import-gitlab-commits 22 | 23 | .PHONY: test 24 | test: 25 | @echo test 26 | @go test -shuffle=on -count=1 -race -v ./... 27 | 28 | .PHONY: test-integration 29 | test-integration: 30 | @echo test-integration 31 | @go test -tags=integration -run=TestGitLab -shuffle=on -count=1 -race -v ./... 32 | 33 | .PHONY: lint 34 | lint: gh-lint-version $(GOBIN)/golangci-lint 35 | @echo lint 36 | @$(GOBIN)/golangci-lint run 37 | 38 | $(GOBIN)/golangci-lint: 39 | @curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b "$(GOBIN)" "$(GOLANGCI_LINT_VERSION)" 40 | 41 | .PHONY: gh-lint-version 42 | gh-lint-version: 43 | @echo "GOLANGCI_LINT_VERSION=$(GOLANGCI_LINT_VERSION)" 44 | 45 | .PHONY: format 46 | format: 47 | @echo format 48 | @go fmt $(PKGS) 49 | 50 | .PHONY: generate 51 | generate: 52 | @echo generate 53 | @go generate ./... 54 | 55 | .PHONY: run 56 | run: 57 | @echo run 58 | @go run -race . 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Import GitLab Commits 2 | 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/alexandear/import-gitlab-commits)](https://goreportcard.com/report/github.com/alexandear/import-gitlab-commits) 4 | 5 | The tool to import commits from private GitLab to separate repo. Can be used to show your programming activity for another company in GitHub. 6 | 7 | Check out this informative blog post for a practical use case on how to import GitLab commits [here](https://alexandear.github.io/posts/2023-03-08-import-gitlab-commits/). 8 | 9 | ## Getting Started 10 | 11 | 1. Download and install [Go](https://go.dev/dl/). 12 | 2. Install the program by running the command in a shell: 13 | 14 | ```shell 15 | go install github.com/alexandear/import-gitlab-commits@latest 16 | ``` 17 | 18 | 3. Set environment variables and run `import-gitlab-commits`: 19 | 20 | ```shell 21 | export GITLAB_BASE_URL= 22 | export GITLAB_TOKEN= 23 | export COMMITTER_NAME="" 24 | export COMMITTER_EMAIL= 25 | 26 | $(go env GOPATH)/bin/import-gitlab-commits 27 | ``` 28 | 29 | where 30 | 31 | - `GITLAB_BASE_URL` is a GitLab [instance URL](https://stackoverflow.com/questions/58236175/what-is-a-gitlab-instance-url-and-how-can-i-get-it), e.g. `https://gitlab.com`, `https://gitlab.gnome.org` or any GitLab server; 32 | - `GITLAB_TOKEN` is a personal [access token](https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#create-a-personal-access-token); 33 | - `COMMITTER_NAME` is your GitHub name with surname, e.g. `John Doe` (can be passed to `git config user.name`); 34 | - `COMMITTER_EMAIL` is your GitHub email, e.g. `john.doe@example.com` (valid for `git config user.email`); 35 | - `$(go env GOPATH)/bin/` is the path where `import-gitlab-commits` installed. 36 | 37 | ## Example 38 | 39 | Contributions before running `import-gitlab-commits`: 40 | 41 | 42 | 43 | 44 | Screenshot of GitHub contributions graph before running import-gitlab-commits 45 | 46 | 47 | After: 48 | 49 | 50 | 51 | 52 | Screenshot of GitHub contributions graph after running import-gitlab-commits with a lot of activity 53 | 54 | 55 | ## Internals 56 | 57 | What work the tool does: 58 | 59 | - gets current user info by `GITLAB_TOKEN`; 60 | - fetches from `GITLAB_BASE_URL` projects that the current user contributed to; 61 | - for all projects fetches commits where author's email is the current user's email; 62 | - creates new repo `repo.gitlab.yourcompany.com.currentusername` and commits all fetched commits with message 63 | `Project: GITLAB_PROJECT_ID commit: GITLAB_COMMIT_HASH`, commit date `GITLAB_COMMIT_DATE`, and commit author `COMMITTER_NAME `. 64 | 65 | To show the changes on GitHub you need to: 66 | 67 | - create a new repo `yourcompany-contributions` in GitHub; 68 | - open folder `repo.gitlab.yourcompany.com.currentusername`; 69 | - add remote url `git remote add origin git@github.com:username/yourcompany-contributions.git`; 70 | - push changes. 71 | 72 | ### Integration Tests 73 | 74 | To run integration tests: 75 | 76 | 1. Set `GITLAB_TOKEN` environment variables with the value obtained at . Necessary scopes: 77 | - `read_api`; 78 | - `read_user`; 79 | - `read_repository`. 80 | 81 | 2. Set `GITLAB_BASE_URL` with `https://gitlab.com`. 82 | 3. Run `make test-integration`. 83 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/alexandear/import-gitlab-commits 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/go-git/go-git/v5 v5.13.1 7 | github.com/stretchr/testify v1.10.0 8 | github.com/xanzy/go-gitlab v0.43.0 9 | ) 10 | 11 | require ( 12 | dario.cat/mergo v1.0.0 // indirect 13 | github.com/Microsoft/go-winio v0.6.1 // indirect 14 | github.com/ProtonMail/go-crypto v1.1.3 // indirect 15 | github.com/cloudflare/circl v1.3.7 // indirect 16 | github.com/cyphar/filepath-securejoin v0.3.6 // indirect 17 | github.com/davecgh/go-spew v1.1.1 // indirect 18 | github.com/emirpasic/gods v1.18.1 // indirect 19 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 20 | github.com/go-git/go-billy/v5 v5.6.1 // indirect 21 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 22 | github.com/golang/protobuf v1.4.2 // indirect 23 | github.com/google/go-querystring v1.0.0 // indirect 24 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 25 | github.com/hashicorp/go-retryablehttp v0.7.7 // indirect 26 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 27 | github.com/kevinburke/ssh_config v1.2.0 // indirect 28 | github.com/pjbgf/sha1cd v0.3.0 // indirect 29 | github.com/pmezard/go-difflib v1.0.0 // indirect 30 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 31 | github.com/skeema/knownhosts v1.3.0 // indirect 32 | github.com/xanzy/ssh-agent v0.3.3 // indirect 33 | golang.org/x/crypto v0.31.0 // indirect 34 | golang.org/x/mod v0.17.0 // indirect 35 | golang.org/x/net v0.33.0 // indirect 36 | golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 // indirect 37 | golang.org/x/sync v0.10.0 // indirect 38 | golang.org/x/sys v0.28.0 // indirect 39 | golang.org/x/time v0.0.0-20191024005414-555d28b269f0 // indirect 40 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect 41 | google.golang.org/appengine v1.6.1 // indirect 42 | google.golang.org/protobuf v1.23.0 // indirect 43 | gopkg.in/warnings.v0 v0.1.2 // indirect 44 | gopkg.in/yaml.v3 v3.0.1 // indirect 45 | ) 46 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= 3 | dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 4 | github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= 5 | github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= 6 | github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= 7 | github.com/ProtonMail/go-crypto v1.1.3 h1:nRBOetoydLeUb4nHajyO2bKqMLfWQ/ZPwkXqXxPxCFk= 8 | github.com/ProtonMail/go-crypto v1.1.3/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= 9 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 10 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 11 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 12 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 13 | github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= 14 | github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= 15 | github.com/cyphar/filepath-securejoin v0.3.6 h1:4d9N5ykBnSp5Xn2JkhocYDkOpURL/18CYMpo6xB9uWM= 16 | github.com/cyphar/filepath-securejoin v0.3.6/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= 17 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 18 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 19 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 20 | github.com/elazarl/goproxy v1.2.3 h1:xwIyKHbaP5yfT6O9KIeYJR5549MXRQkoQMRXGztz8YQ= 21 | github.com/elazarl/goproxy v1.2.3/go.mod h1:YfEbZtqP4AetfO6d40vWchF3znWX7C7Vd6ZMfdL8z64= 22 | github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 23 | github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 24 | github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 25 | github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 26 | github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 27 | github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 28 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= 29 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= 30 | github.com/go-git/go-billy/v5 v5.6.1 h1:u+dcrgaguSSkbjzHwelEjc0Yj300NUevrrPphk/SoRA= 31 | github.com/go-git/go-billy/v5 v5.6.1/go.mod h1:0AsLr1z2+Uksi4NlElmMblP5rPcDZNRCD8ujZCRR2BE= 32 | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= 33 | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= 34 | github.com/go-git/go-git/v5 v5.13.1 h1:DAQ9APonnlvSWpvolXWIuV6Q6zXy2wHbN4cVlNR5Q+M= 35 | github.com/go-git/go-git/v5 v5.13.1/go.mod h1:qryJB4cSBoq3FRoBRf5A77joojuBcmPJ0qu3XXXVixc= 36 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 37 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 38 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 39 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 40 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 41 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 42 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 43 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 44 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 45 | github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0= 46 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 47 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 48 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 49 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 50 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 51 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 52 | github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= 53 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 54 | github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= 55 | github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 56 | github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 57 | github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= 58 | github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 59 | github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= 60 | github.com/hashicorp/go-retryablehttp v0.6.4/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= 61 | github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= 62 | github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= 63 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= 64 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 65 | github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= 66 | github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 67 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 68 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 69 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 70 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 71 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 72 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 73 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 74 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 75 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 76 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 77 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 78 | github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= 79 | github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= 80 | github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= 81 | github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= 82 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 83 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 84 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 85 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 86 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 87 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 88 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= 89 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= 90 | github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 91 | github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY= 92 | github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M= 93 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 94 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 95 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 96 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 97 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 98 | github.com/xanzy/go-gitlab v0.43.0 h1:rpOZQjxVJGW/ch+Jy4j7W4o7BB1mxkXJNVGuplZ7PUs= 99 | github.com/xanzy/go-gitlab v0.43.0/go.mod h1:sPLojNBn68fMUWSxIJtdVVIP8uSBYqesTfDUseX11Ug= 100 | github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= 101 | github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= 102 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 103 | golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 104 | golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 105 | golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 106 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 107 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= 108 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 109 | golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= 110 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 111 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 112 | golang.org/x/net v0.0.0-20181108082009-03003ca0c849/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 113 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 114 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 115 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 116 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 117 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 118 | golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 119 | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 120 | golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 121 | golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0= 122 | golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 123 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 124 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 125 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 126 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 127 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 128 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 129 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 130 | golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 131 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 132 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 133 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 134 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 135 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 136 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 137 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 138 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 139 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 140 | golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= 141 | golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 142 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 143 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 144 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 145 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 146 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 147 | golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs= 148 | golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 149 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 150 | golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 151 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= 152 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 153 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 154 | google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 155 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 156 | google.golang.org/appengine v1.6.1 h1:QzqyMA1tlu6CgqCDUtU9V+ZKhLFT2dkJuANu5QaxI3I= 157 | google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= 158 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 159 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 160 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 161 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 162 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 163 | google.golang.org/protobuf v1.23.0 h1:4MY060fB1DLGMB/7MBTLnwQUY6+F09GEiz6SsrNqyzM= 164 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 165 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 166 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 167 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 168 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 169 | gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 170 | gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 171 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 172 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 173 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 174 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 175 | -------------------------------------------------------------------------------- /internal/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "net/url" 9 | "strings" 10 | "time" 11 | 12 | "github.com/go-git/go-git/v5" 13 | "github.com/go-git/go-git/v5/plumbing" 14 | "github.com/go-git/go-git/v5/plumbing/object" 15 | gogitlab "github.com/xanzy/go-gitlab" 16 | ) 17 | 18 | const ( 19 | getCurrentUserTimeout = 2 * time.Second 20 | 21 | maxProjects = 1000 22 | ) 23 | 24 | type App struct { 25 | logger *log.Logger 26 | 27 | gitlabBaseURL *url.URL 28 | gitlab *GitLab 29 | 30 | committerName string 31 | committerEmail string 32 | } 33 | 34 | type User struct { 35 | Name string 36 | Emails []string 37 | Username string 38 | CreatedAt time.Time 39 | } 40 | 41 | func New(logger *log.Logger, gitlabToken string, gitlabBaseURL *url.URL, committerName, committerEmail string, 42 | ) (*App, error) { 43 | gitlabClient, err := gogitlab.NewClient(gitlabToken, gogitlab.WithBaseURL(gitlabBaseURL.String())) 44 | if err != nil { 45 | return nil, fmt.Errorf("create GitLab client: %w", err) 46 | } 47 | 48 | f := NewGitLab(logger, gitlabClient) 49 | 50 | return &App{ 51 | logger: logger, 52 | gitlab: f, 53 | gitlabBaseURL: gitlabBaseURL, 54 | committerName: committerName, 55 | committerEmail: committerEmail, 56 | }, nil 57 | } 58 | 59 | func (a *App) Run(ctx context.Context) error { 60 | ctxCurrent, cancel := context.WithTimeout(ctx, getCurrentUserTimeout) 61 | defer cancel() 62 | 63 | currentUser, err := a.gitlab.CurrentUser(ctxCurrent) 64 | if err != nil { 65 | return fmt.Errorf("get current user: %w", err) 66 | } 67 | 68 | a.logger.Printf("Found current user %q", currentUser.Name) 69 | 70 | repoPath := "./" + repoName(a.gitlabBaseURL, currentUser) 71 | 72 | repo, err := a.createOrOpenRepo(repoPath) 73 | if err != nil { 74 | return fmt.Errorf("create or open repo: %w", err) 75 | } 76 | 77 | worktree, err := repo.Worktree() 78 | if err != nil { 79 | return fmt.Errorf("get worktree: %w", err) 80 | } 81 | 82 | lastCommitDate := a.lastCommitDate(repo) 83 | 84 | projectCommitCounter := make(map[int]int, maxProjects) 85 | 86 | projectID := 0 87 | page := 1 88 | 89 | for page > 0 { 90 | projects, nextPage, errFetch := a.gitlab.FetchProjectPage(ctx, page, currentUser, projectID) 91 | if errFetch != nil { 92 | return fmt.Errorf("fetch projects: %w", errFetch) 93 | } 94 | 95 | for _, project := range projects { 96 | commits, errCommit := a.doCommitsForProject(ctx, worktree, currentUser, project, lastCommitDate) 97 | if errCommit != nil { 98 | return fmt.Errorf("do commits: %w", errCommit) 99 | } 100 | 101 | projectCommitCounter[projectID] = commits 102 | } 103 | 104 | page = nextPage 105 | } 106 | 107 | for project, commit := range projectCommitCounter { 108 | a.logger.Printf("project %d: commits %d", project, commit) 109 | } 110 | 111 | return nil 112 | } 113 | 114 | func (a *App) createOrOpenRepo(repoPath string) (*git.Repository, error) { 115 | repo, err := git.PlainInit(repoPath, false) 116 | if err == nil { 117 | a.logger.Printf("Init repository %q", repoPath) 118 | 119 | return repo, nil 120 | } 121 | 122 | if errors.Is(err, git.ErrRepositoryAlreadyExists) { 123 | a.logger.Printf("Repository %q already exists, opening it", repoPath) 124 | 125 | repo, err = git.PlainOpen(repoPath) 126 | if err != nil { 127 | return nil, fmt.Errorf("open: %w", err) 128 | } 129 | 130 | return repo, nil 131 | } 132 | 133 | return nil, fmt.Errorf("init: %w", err) 134 | } 135 | 136 | func (a *App) lastCommitDate(repo *git.Repository) time.Time { 137 | head, err := repo.Head() 138 | if err != nil { 139 | if !errors.Is(err, plumbing.ErrReferenceNotFound) { 140 | a.logger.Printf("Failed to get repo head: %v", err) 141 | } 142 | 143 | return time.Time{} 144 | } 145 | 146 | headCommit, err := repo.CommitObject(head.Hash()) 147 | if err != nil { 148 | a.logger.Printf("Failed to get head commit: %v", err) 149 | 150 | return time.Time{} 151 | } 152 | 153 | projectID, _, err := ParseCommitMessage(headCommit.Message) 154 | if err != nil { 155 | a.logger.Printf("Failed to parse commit message: %v", err) 156 | 157 | return time.Time{} 158 | } 159 | 160 | lastCommitDate := headCommit.Committer.When 161 | 162 | a.logger.Printf("Found last project id %d and last commit date %v", projectID, lastCommitDate) 163 | 164 | return lastCommitDate 165 | } 166 | 167 | func (a *App) doCommitsForProject( 168 | ctx context.Context, worktree *git.Worktree, currentUser *User, projectID int, lastCommitDate time.Time, 169 | ) (int, error) { 170 | commits, err := a.gitlab.FetchCommits(ctx, currentUser, projectID, lastCommitDate) 171 | if err != nil { 172 | return 0, fmt.Errorf("fetch commits: %w", err) 173 | } 174 | 175 | a.logger.Printf("Fetched %d commits for project %d", len(commits), projectID) 176 | 177 | var commitCounter int 178 | 179 | committer := &object.Signature{ 180 | Name: a.committerName, 181 | Email: a.committerEmail, 182 | } 183 | 184 | for _, commit := range commits { 185 | committer.When = commit.CommittedAt 186 | 187 | if _, errCommit := worktree.Commit(commit.Message, &git.CommitOptions{ 188 | Author: committer, 189 | Committer: committer, 190 | AllowEmptyCommits: true, 191 | }); errCommit != nil { 192 | return commitCounter, fmt.Errorf("commit: %w", errCommit) 193 | } 194 | 195 | commitCounter++ 196 | } 197 | 198 | return commitCounter, nil 199 | } 200 | 201 | // repoName generates unique repo name for the user. 202 | func repoName(baseURL *url.URL, user *User) string { 203 | host := baseURL.Host 204 | 205 | const hostPortLen = 2 206 | 207 | hostPort := strings.Split(host, ":") 208 | if len(hostPort) > hostPortLen { 209 | host = hostPort[0] 210 | } 211 | 212 | return "repo." + host + "." + user.Username 213 | } 214 | -------------------------------------------------------------------------------- /internal/commit.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | type Commit struct { 11 | CommittedAt time.Time 12 | Message string 13 | } 14 | 15 | func NewCommit(committedAt time.Time, projectID int, hash string) *Commit { 16 | return &Commit{ 17 | CommittedAt: committedAt, 18 | Message: fmt.Sprintf("Project: %d commit: %s", projectID, hash), 19 | } 20 | } 21 | 22 | func ParseCommitMessage(message string) (projectID int, hash string, _ error) { 23 | const messagePartsCount = 4 24 | 25 | messageParts := strings.Split(message, " ") 26 | if len(messageParts) < messagePartsCount { 27 | return 0, "", fmt.Errorf("wrong commit message: %s", message) 28 | } 29 | 30 | id, err := strconv.Atoi(messageParts[1]) 31 | if err != nil { 32 | return 0, "", fmt.Errorf("failed to convert %s to project id: %w", messageParts[1], err) 33 | } 34 | 35 | projectID = id 36 | hash = messageParts[3] 37 | 38 | return projectID, hash, nil 39 | } 40 | -------------------------------------------------------------------------------- /internal/commit_test.go: -------------------------------------------------------------------------------- 1 | package app_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | 10 | app "github.com/alexandear/import-gitlab-commits/internal" 11 | ) 12 | 13 | func TestNewCommit(t *testing.T) { 14 | committedAt, err := time.Parse(time.RFC3339, "2021-08-01T12:00:00Z") 15 | if err != nil { 16 | t.Fatal(err) 17 | } 18 | 19 | projectID := 2323 20 | hash := "9bc457f81c86307f28662b40a164105f14df64e3" 21 | 22 | commit := app.NewCommit(committedAt, projectID, hash) 23 | 24 | assert.Equal(t, &app.Commit{ 25 | CommittedAt: committedAt, 26 | Message: "Project: 2323 commit: 9bc457f81c86307f28662b40a164105f14df64e3", 27 | }, commit) 28 | } 29 | 30 | func TestParseCommitMessage(t *testing.T) { 31 | t.Run("valid", func(t *testing.T) { 32 | msg := "Project: 2323 commit: 9bc457f81c86307f28662b40a164105f14df64e3" 33 | projectID, hash, err := app.ParseCommitMessage(msg) 34 | 35 | require.NoError(t, err) 36 | assert.Equal(t, 2323, projectID) 37 | assert.Equal(t, "9bc457f81c86307f28662b40a164105f14df64e3", hash) 38 | }) 39 | 40 | t.Run("wrong project id", func(t *testing.T) { 41 | msg := "Project: PROJ commit: 9bc457f81c86307f28662b40a164105f14df64e3" 42 | _, _, err := app.ParseCommitMessage(msg) 43 | 44 | assert.Error(t, err) 45 | }) 46 | } 47 | -------------------------------------------------------------------------------- /internal/gitlab.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "slices" 8 | "strings" 9 | "time" 10 | 11 | "github.com/xanzy/go-gitlab" 12 | ) 13 | 14 | const ( 15 | maxCommits = 1000 16 | ) 17 | 18 | type GitLab struct { 19 | logger *log.Logger 20 | 21 | gitlabClient *gitlab.Client 22 | } 23 | 24 | func NewGitLab(logger *log.Logger, gitlabClient *gitlab.Client) *GitLab { 25 | return &GitLab{ 26 | logger: logger, 27 | gitlabClient: gitlabClient, 28 | } 29 | } 30 | 31 | func (s *GitLab) CurrentUser(ctx context.Context) (*User, error) { 32 | user, _, err := s.gitlabClient.Users.CurrentUser(gitlab.WithContext(ctx)) 33 | if err != nil { 34 | return nil, fmt.Errorf("get current user: %w", err) 35 | } 36 | 37 | emails, _, err := s.gitlabClient.Users.ListEmails(gitlab.WithContext(ctx)) 38 | if err != nil { 39 | return nil, fmt.Errorf("get user emails: %w", err) 40 | } 41 | 42 | emailAddresses := make([]string, 0, len(emails)) 43 | for _, email := range emails { 44 | emailAddresses = append(emailAddresses, email.Email) 45 | } 46 | 47 | return &User{ 48 | Name: user.Name, 49 | Emails: emailAddresses, 50 | Username: user.Username, 51 | CreatedAt: *user.CreatedAt, 52 | }, nil 53 | } 54 | 55 | func (s *GitLab) FetchProjectPage(ctx context.Context, page int, user *User, idAfter int, 56 | ) (_ []int, nextPage int, _ error) { 57 | const perPage = 100 58 | 59 | projects := make([]int, 0, perPage) 60 | 61 | opt := &gitlab.ListProjectsOptions{ 62 | ListOptions: gitlab.ListOptions{ 63 | Page: page, 64 | PerPage: perPage, 65 | }, 66 | OrderBy: gitlab.String("id"), 67 | Sort: gitlab.String("asc"), 68 | Simple: gitlab.Bool(true), 69 | Membership: gitlab.Bool(true), 70 | IDAfter: gitlab.Int(idAfter), 71 | } 72 | 73 | projs, resp, err := s.gitlabClient.Projects.ListProjects(opt, gitlab.WithContext(ctx)) 74 | if err != nil { 75 | return nil, 0, fmt.Errorf("list projects: %w", err) 76 | } 77 | 78 | for _, proj := range projs { 79 | if !s.HasUserContributions(ctx, user, proj.ID) { 80 | continue 81 | } 82 | 83 | s.logger.Printf("Fetching project: %d", proj.ID) 84 | 85 | projects = append(projects, proj.ID) 86 | } 87 | 88 | if resp.CurrentPage >= resp.TotalPages { 89 | return projects, 0, nil 90 | } 91 | 92 | return projects, resp.NextPage, nil 93 | } 94 | 95 | func (s *GitLab) HasUserContributions(ctx context.Context, user *User, projectID int) bool { 96 | const perPage = 50 97 | 98 | opt := &gitlab.ListContributorsOptions{ 99 | ListOptions: gitlab.ListOptions{ 100 | PerPage: perPage, 101 | Page: 1, 102 | }, 103 | } 104 | 105 | for { 106 | contrs, resp, err := s.gitlabClient.Repositories.Contributors(projectID, opt, gitlab.WithContext(ctx)) 107 | if err != nil { 108 | s.logger.Printf("get contributors for project %d: %v", projectID, err) 109 | 110 | return false 111 | } 112 | 113 | for _, c := range contrs { 114 | if contains(user.Emails, c.Email) { 115 | return true 116 | } 117 | } 118 | 119 | if resp.CurrentPage >= resp.TotalPages { 120 | break 121 | } 122 | 123 | opt.Page = resp.NextPage 124 | } 125 | 126 | return false 127 | } 128 | 129 | func (s *GitLab) FetchCommits(ctx context.Context, user *User, projectID int, since time.Time, 130 | ) ([]*Commit, error) { 131 | commits := make([]*Commit, 0, maxCommits) 132 | 133 | const commitsPerPage = 100 134 | 135 | page := 1 136 | for page > 0 { 137 | cms, nextPage, err := s.fetchCommitPage(ctx, user, page, commitsPerPage, since, projectID) 138 | if err != nil { 139 | return nil, fmt.Errorf("fetch one commit page: %w", err) 140 | } 141 | 142 | commits = append(commits, cms...) 143 | page = nextPage 144 | } 145 | 146 | // Reverse slice. 147 | for i, j := 0, len(commits)-1; i < j; i, j = i+1, j-1 { 148 | commits[i], commits[j] = commits[j], commits[i] 149 | } 150 | 151 | return commits, nil 152 | } 153 | 154 | func (s *GitLab) fetchCommitPage( 155 | ctx context.Context, user *User, page, perPage int, since time.Time, projectID int, 156 | ) (commits []*Commit, nextPage int, err error) { 157 | commits = make([]*Commit, 0, perPage) 158 | 159 | opt := &gitlab.ListCommitsOptions{ 160 | ListOptions: gitlab.ListOptions{ 161 | PerPage: perPage, 162 | Page: page, 163 | }, 164 | All: gitlab.Bool(true), 165 | } 166 | 167 | if !since.IsZero() { 168 | opt.Since = gitlab.Time(since) 169 | } 170 | 171 | comms, resp, err := s.gitlabClient.Commits.ListCommits(projectID, opt, gitlab.WithContext(ctx)) 172 | if err != nil { 173 | return nil, 0, fmt.Errorf("get commits for project %d: %w", projectID, err) 174 | } 175 | 176 | for _, comm := range comms { 177 | if !contains(user.Emails, comm.AuthorEmail) || !contains(user.Emails, comm.CommitterEmail) { 178 | continue 179 | } 180 | 181 | s.logger.Printf("fetching commit: %s %s", comm.ShortID, comm.CommittedDate) 182 | 183 | commits = append(commits, NewCommit(*comm.CommittedDate, projectID, comm.ID)) 184 | } 185 | 186 | // For performance reasons, if a query returns more than 10,000 records, GitLab 187 | // doesn't return TotalPages. 188 | if resp.TotalPages == 0 { 189 | return commits, resp.NextPage, nil 190 | } 191 | 192 | if resp.CurrentPage >= resp.TotalPages { 193 | return commits, 0, nil 194 | } 195 | 196 | return commits, resp.NextPage, nil 197 | } 198 | 199 | // contains checks if a string `v` is in the slice `s`, ignoring case. 200 | func contains(s []string, v string) bool { 201 | return slices.ContainsFunc(s, func(item string) bool { 202 | return strings.EqualFold(item, v) 203 | }) 204 | } 205 | -------------------------------------------------------------------------------- /internal/gitlab_test.go: -------------------------------------------------------------------------------- 1 | //go:build integration 2 | 3 | package app_test 4 | 5 | import ( 6 | "context" 7 | "os" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "github.com/xanzy/go-gitlab" 13 | 14 | app "github.com/alexandear/import-gitlab-commits/internal" 15 | "github.com/alexandear/import-gitlab-commits/internal/testutil" 16 | ) 17 | 18 | func TestGitLabCurrentUser(t *testing.T) { 19 | gl := app.NewGitLab(testutil.NewLog(t), gitlabClient(t)) 20 | 21 | user, err := gl.CurrentUser(context.Background()) 22 | 23 | require.NoError(t, err) 24 | assert.NotEmpty(t, user.Name) 25 | assert.NotEmpty(t, user.Emails) 26 | assert.NotEmpty(t, user.Username) 27 | assert.False(t, user.CreatedAt.IsZero()) 28 | } 29 | 30 | func gitlabClient(t *testing.T) *gitlab.Client { 31 | t.Helper() 32 | 33 | token := os.Getenv("GITLAB_TOKEN") 34 | if token == "" { 35 | t.Fatal("GITLAB_TOKEN is required") 36 | } 37 | 38 | baseURL := os.Getenv("GITLAB_BASE_URL") 39 | if baseURL == "" { 40 | t.Fatal("GITLAB_BASE_URL is required") 41 | } 42 | 43 | client, err := gitlab.NewClient(token, gitlab.WithBaseURL(baseURL)) 44 | require.NoError(t, err) 45 | 46 | return client 47 | } 48 | -------------------------------------------------------------------------------- /internal/testutil/log.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "log" 5 | "testing" 6 | ) 7 | 8 | // Writer used for a logger in tests. 9 | type Writer struct { 10 | t *testing.T 11 | } 12 | 13 | func NewLog(t *testing.T) *log.Logger { 14 | t.Helper() 15 | 16 | return log.New(NewWriter(t), "", log.Lshortfile|log.Ltime) 17 | } 18 | 19 | func NewWriter(t *testing.T) *Writer { 20 | t.Helper() 21 | 22 | return &Writer{t: t} 23 | } 24 | 25 | func (w *Writer) Write(p []byte) (n int, err error) { 26 | str := string(p) 27 | 28 | w.t.Log(str[:len(str)-1]) 29 | 30 | return len(p), nil 31 | } 32 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "net/url" 9 | "os" 10 | "time" 11 | 12 | app "github.com/alexandear/import-gitlab-commits/internal" 13 | ) 14 | 15 | const ( 16 | runTimeout = 10 * time.Minute 17 | ) 18 | 19 | func Execute(logger *log.Logger) error { 20 | token := os.Getenv("GITLAB_TOKEN") 21 | if token == "" { 22 | return errors.New(`empty GITLAB_TOKEN, example "yourgitlabtoken"`) 23 | } 24 | 25 | baseURL, err := url.Parse(os.Getenv("GITLAB_BASE_URL")) 26 | if err != nil { 27 | return errors.New(`wrong GITLAB_BASE_URL, example "https://gitlab.com"`) 28 | } 29 | 30 | committerName := os.Getenv("COMMITTER_NAME") 31 | if committerName == "" { 32 | return errors.New(`empty COMMITTER_NAME, example "John Doe"`) 33 | } 34 | 35 | committerEmail := os.Getenv("COMMITTER_EMAIL") 36 | if committerEmail == "" { 37 | return errors.New(`empty COMMITTER_EMAIL, example "john.doe@example.com"`) 38 | } 39 | 40 | application, err := app.New(logger, token, baseURL, committerName, committerEmail) 41 | if err != nil { 42 | return fmt.Errorf("create app: %w", err) 43 | } 44 | 45 | ctx, cancel := context.WithTimeout(context.Background(), runTimeout) 46 | defer cancel() 47 | 48 | if err := application.Run(ctx); err != nil { 49 | return fmt.Errorf("app run: %w", err) 50 | } 51 | 52 | return nil 53 | } 54 | 55 | func main() { 56 | logger := log.New(os.Stdout, "", log.Lshortfile|log.Ltime) 57 | 58 | if err := Execute(logger); err != nil { 59 | logger.Fatalln("Error:", err) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | 8 | "github.com/alexandear/import-gitlab-commits/internal/testutil" 9 | ) 10 | 11 | func TestExecute(t *testing.T) { 12 | t.Run("error when wrong GITLAB_TOKEN", func(t *testing.T) { 13 | t.Setenv("GITLAB_TOKEN", "") 14 | 15 | err := Execute(testutil.NewLog(t)) 16 | 17 | require.ErrorContains(t, err, "GITLAB_TOKEN") 18 | }) 19 | 20 | t.Run("error when wrong GITLAB_BASE_URL", func(t *testing.T) { 21 | t.Setenv("GITLAB_TOKEN", "yourgitlabtoken") 22 | t.Setenv("GITLAB_BASE_URL", ":") 23 | 24 | err := Execute(testutil.NewLog(t)) 25 | 26 | require.ErrorContains(t, err, "GITLAB_BASE_URL") 27 | }) 28 | 29 | t.Run("error when wrong COMMITTER_NAME", func(t *testing.T) { 30 | t.Setenv("GITLAB_TOKEN", "yourgitlabtoken") 31 | t.Setenv("GITLAB_BASE_URL", "https://gitlab.com") 32 | t.Setenv("COMMITTER_NAME", "") 33 | 34 | err := Execute(testutil.NewLog(t)) 35 | 36 | require.ErrorContains(t, err, "COMMITTER_NAME") 37 | }) 38 | 39 | t.Run("error when wrong COMMITTER_EMAIL", func(t *testing.T) { 40 | t.Setenv("GITLAB_TOKEN", "yourgitlabtoken") 41 | t.Setenv("GITLAB_BASE_URL", "https://gitlab.com") 42 | t.Setenv("COMMITTER_NAME", "John Doe") 43 | t.Setenv("COMMITTER_EMAIL", "") 44 | 45 | err := Execute(testutil.NewLog(t)) 46 | 47 | require.ErrorContains(t, err, "COMMITTER_EMAIL") 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /screenshots/contribs_after_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexandear/import-gitlab-commits/2042e31381ebc094be6160f4b0f576c406c39a6a/screenshots/contribs_after_dark.png -------------------------------------------------------------------------------- /screenshots/contribs_after_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexandear/import-gitlab-commits/2042e31381ebc094be6160f4b0f576c406c39a6a/screenshots/contribs_after_light.png -------------------------------------------------------------------------------- /screenshots/contribs_before_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexandear/import-gitlab-commits/2042e31381ebc094be6160f4b0f576c406c39a6a/screenshots/contribs_before_dark.png -------------------------------------------------------------------------------- /screenshots/contribs_before_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexandear/import-gitlab-commits/2042e31381ebc094be6160f4b0f576c406c39a6a/screenshots/contribs_before_light.png -------------------------------------------------------------------------------- /tools/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: golangci-lint-version 2 | golangci-lint-version: 3 | @go list -m -f '{{.Version}}' github.com/golangci/golangci-lint/v2 4 | -------------------------------------------------------------------------------- /tools/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/alexandear/import-gitlab-commits/tools 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.1 6 | 7 | require github.com/golangci/golangci-lint/v2 v2.0.2 8 | -------------------------------------------------------------------------------- /tools/go.sum: -------------------------------------------------------------------------------- 1 | github.com/golangci/golangci-lint/v2 v2.0.2 h1:dMCC8ikPiLDvHMFy3+XypSAuGDBOLzwWqqamer+bWsY= 2 | github.com/golangci/golangci-lint/v2 v2.0.2/go.mod h1:ptNNMeGBQrbves0Qq38xvfdJg18PzxmT+7KRCOpm6i8= 3 | -------------------------------------------------------------------------------- /tools/pinversion.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | 3 | package tools 4 | 5 | // Keep a reference to the code generators so they are not removed by go mod tidy 6 | import ( 7 | _ "github.com/golangci/golangci-lint/v2/pkg/exitcodes" 8 | ) 9 | --------------------------------------------------------------------------------