├── .github └── workflows │ └── go.yml ├── .gitignore ├── .gitlab-ci.yml ├── .travis.yml ├── AUTHORS ├── CHANGELOG ├── LICENSE ├── Makefile ├── README.md ├── cmd └── gitlab-copy │ ├── main.go │ └── main_test.go ├── config ├── config.go ├── config_test.go ├── project.go └── version.go ├── gitlab ├── client.go └── contract.go ├── go.mod ├── go.sum ├── migration ├── client_test.go ├── configs_test.go ├── issue.go └── issue_test.go ├── qr-donate.png ├── stats └── project.go ├── tools └── coverage.sh └── version.mk /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v3 21 | with: 22 | go-version: 1.19 23 | 24 | - name: Build 25 | run: go build -v ./... 26 | 27 | - name: Test 28 | run: go test -v ./... 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | pkg 3 | *.swp 4 | tags 5 | .cover 6 | dist 7 | public 8 | ssl 9 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: golang:latest 2 | 3 | before_script: 4 | - go get github.com/constabulary/gb/... 5 | 6 | build: 7 | script: 8 | - make 9 | 10 | test: 11 | script: 12 | - make test 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - "1.11.2" 5 | 6 | script: 7 | - make 8 | - go test ./... -cover 9 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Mathias Monnerville @matm 2 | Anthony Baillard @aboutofpluto 3 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | v 0.8.2 2 | - migrate: panic on nil pointer. #66 3 | 4 | v 0.8.1 5 | - build: refac the way git revisions are passed to the compiler. #65 6 | - Add Go report card badge into README. #59 7 | - Fix dependencies security issues. #61 8 | 9 | v 0.8.0 10 | - Use go.mod instead of Gopkg and upgrade dependencies. #55 11 | - Built with Go 1.20.1 12 | 13 | v 0.7.0 14 | - Use GitLab API v4. #29 15 | - Better testing through API mocking and unit tests. #41 16 | - Issue linked attachments are transferred. #15 17 | - Use dep to vendor dependencies. #31 18 | - dist: Windows binary is missing .exe suffix. #28 19 | - Fixed segfault when from project has no milestones. #27 20 | - Update go-gitlab library to v0.10.5. 21 | - Built with Go 1.12 22 | 23 | v 0.6.7 24 | - Copy labels description too. #16 25 | - More verbose output for `-version` flag 26 | - vendor: `xanzy/go-gitlab` library updated to v0.2.1 27 | - Built with Go 1.7 28 | 29 | v 0.6.6 30 | - Add moving issues capability, with `moveIssues`. #1 31 | - Add support for user ID mapping in notes, with `users`. #5 32 | - Auto-close an issue after copy, with a link to new one, with `autoCloseIssues`, 33 | `linkToTargetIssue` and `linkToTargetIssueText` parameters. #6 34 | - Add an option to copy milestones only, with `milestonesOnly`. #13 35 | - Apply labels to closed issues (Manoj Govindan) 36 | 37 | v 0.6.5 38 | - Copy labels only from one project to another. Use the `labelsOnly` 39 | config entry. #9 40 | 41 | v 0.6.4 42 | - Fixes GitLab HTTP 414 content too large, preventing skipping issue 43 | creation on target. #8 44 | 45 | v 0.6.3 46 | - Handles any trailing slash in server URI gracefully. #2 47 | - Label color is preserved during copy. #3 48 | - All source labels are copied to target project, even if not used in issues. #4 49 | 50 | v 0.6.2 51 | - Preserves order of issues 52 | - Doc fixes 53 | 54 | v 0.6.1 55 | - Copy issues from one GitLab to another 56 | - Tries to keep issue labels and milestones 57 | - **Beta** software at the moment 58 | - First public release 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 GoTsunami 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build dist linux darwin windows buildall cleardist clean 2 | 3 | include version.mk 4 | 5 | BIN=gitlab-copy 6 | WINDOWS_BIN=${BIN}.exe 7 | DISTDIR=dist 8 | GCDIR=${DISTDIR}/${BIN} 9 | # 10 | GC_VERSION=${BIN}-${VERSION} 11 | GC_DARWIN_AMD64=${GC_VERSION}-darwin-amd64 12 | GC_FREEBSD_AMD64=${GC_VERSION}-freebsd-amd64 13 | GC_OPENBSD_AMD64=${GC_VERSION}-openbsd-amd64 14 | GC_LINUX_AMD64=${GC_VERSION}-linux-amd64 15 | GC_WINDOWS_AMD64=${GC_VERSION}-windows-amd64 16 | # 17 | GB_BUILD64=GOARCH=amd64 go build 18 | MAIN_CMD=github.com/gotsunami/${BIN}/cmd/${BIN} 19 | 20 | all: build 21 | 22 | build: 23 | @go build -ldflags "all=$(GO_LDFLAGS)" -o bin/${BIN} ${MAIN_CMD} 24 | 25 | test: 26 | @go test ./... -coverprofile=/tmp/cover.out 27 | @go tool cover -html=/tmp/cover.out -o /tmp/coverage.html 28 | 29 | checksum: 30 | @for f in ${DISTDIR}/*; do \ 31 | sha256sum $$f > $$f.sha256; \ 32 | sed -i 's,${DISTDIR}/,,' $$f.sha256; \ 33 | done 34 | 35 | coverage: 36 | @./tools/coverage.sh `pwd` 37 | 38 | htmlcoverage: 39 | @./tools/coverage.sh --html `pwd` 40 | 41 | dist: cleardist buildall zip sourcearchive checksum 42 | 43 | zip: linux darwin freebsd openbsd windows 44 | @rm -rf ${GCDIR} 45 | 46 | linux: 47 | @cp bin/${GC_VERSION}-linux* ${GCDIR}/${BIN} && \ 48 | (cd ${DISTDIR} && zip -r ${GC_LINUX_AMD64}.zip ${BIN}) 49 | 50 | darwin: 51 | @cp bin/${GC_VERSION}-darwin* ${GCDIR}/${BIN} && \ 52 | (cd ${DISTDIR} && zip -r ${GC_DARWIN_AMD64}.zip ${BIN}) 53 | 54 | windows: 55 | @cp bin/${GC_VERSION}-windows* ${GCDIR}/${WINDOWS_BIN} && \ 56 | (cd ${DISTDIR} && rm ${BIN}/${BIN} && zip -r ${GC_WINDOWS_AMD64}.zip ${BIN}) 57 | 58 | freebsd: 59 | @cp bin/${GC_VERSION}-freebsd* ${GCDIR}/${BIN} && \ 60 | (cd ${DISTDIR} && zip -r ${GC_FREEBSD_AMD64}.zip ${BIN}) 61 | 62 | openbsd: 63 | @cp bin/${GC_VERSION}-openbsd* ${GCDIR}/${BIN} && \ 64 | (cd ${DISTDIR} && zip -r ${GC_OPENBSD_AMD64}.zip ${BIN}) 65 | 66 | buildall: 67 | @GOOS=darwin ${GB_BUILD64} -v -o bin/${GC_DARWIN_AMD64} ${MAIN_CMD} 68 | @GOOS=freebsd ${GB_BUILD64} -v -o bin/${GC_FREEBSD_AMD64} ${MAIN_CMD} 69 | @GOOS=openbsd ${GB_BUILD64} -v -o bin/${GC_OPENBSD_AMD64} ${MAIN_CMD} 70 | @GOOS=linux ${GB_BUILD64} -v -o bin/${GC_LINUX_AMD64} ${MAIN_CMD} 71 | @GOOS=windows ${GB_BUILD64} -v -o bin/${GC_WINDOWS_AMD64} ${MAIN_CMD} 72 | 73 | sourcearchive: 74 | @git archive --format=zip -o ${DISTDIR}/${VERSION}.zip ${VERSION} 75 | @echo ${DISTDIR}/${VERSION}.zip 76 | @git archive -o ${DISTDIR}/${VERSION}.tar ${VERSION} 77 | @gzip ${DISTDIR}/${VERSION}.tar 78 | @echo ${DISTDIR}/${VERSION}.tar.gz 79 | 80 | cleardist: clean 81 | mkdir -p ${GCDIR} 82 | 83 | clean: 84 | @rm -rf bin pkg ${DISTDIR} 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # GitLab Copy 3 | 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/gotsunami/gitlab-copy)](https://goreportcard.com/report/github.com/gotsunami/gitlab-copy) 5 | 6 | **PROJECT ARCHIVED**: unfortunately, due to lack of time, this repository is no longer maintained. 7 | 8 | `gitlab-copy` is a simple tool for copying issues/labels/milestones/notes from one GitLab project to another, possibly running on different GitLab instances. 9 | 10 | By default, `gitlab-copy` won't copy anything until told **explicitly** to do so on the command line: running it will do nothing but showing some statistics. 11 | 12 | **Note**: GitLab 8.6 introduced the ability [to move an issue to another project](https://about.gitlab.com/2016/03/22/gitlab-8-6-released/), but on the same GitLab installation only. `gitlab-copy` can still prove valuable to move issues between projects on different GitLab hosts and to perform batch operations from the command line (see the feature list below). 13 | 14 | ## Download 15 | 16 | Installing `gitlab-copy` is very easy since it comes as a static binary with no dependencies. Just [grab a compiled version](https://github.com/gotsunami/gitlab-copy/releases/latest) for your system (or have a look at the **Compile From Source** section). 17 | 18 | ## Features 19 | 20 | The following features are available: 21 | 22 | - Support for GitLab instances with self-signed TLS certificates by using the `-k` CLI flag (since `v0.8.0`) 23 | - Support for different GitLab hosts/instances (since `v0.8.0`) 24 | - Copy milestones if not existing on target (use `milestonesOnly` to copy milestones only, see below) 25 | - Copy all source labels on target (use `labelsOnly` to copy labels only, see below) 26 | - Copy issues if not existing on target (by title) 27 | - Apply closed status on issues, if any 28 | - Set issue's assignee (if user exists) and milestone, if any 29 | - Copy notes (attached to issues), preserving user ownership 30 | - Can specify in the config file a specific issue or range of issues to copy 31 | - Auto-close source issues after copy 32 | - Add a note with a link to the new issue created in the target project 33 | - Use a custom link text template, like "Closed in favor or me/myotherproject#12" 34 | 35 | ## Getting Started 36 | 37 | Here are some instructions to get started. First make sure you have valid GitLab account tokens for both source and destination GitLab installations. They are used to access GitLab resources without authentication. GitLab private tokens are availble in "*Profile Settings* -> *Account*". 38 | 39 | Now, write a `gitlab.yml` YAML config file to specify source and target projects, along with your GitLab account tokens: 40 | 41 | ```yaml 42 | from: 43 | url: https://gitlab.mydomain.com 44 | token: atoken 45 | project: namespace/project 46 | to: 47 | url: https://gitlab.myotherdomain.com 48 | token: anothertoken 49 | project: namespace/project 50 | ``` 51 | 52 | That's it. You may want to run the program now. See the section below. 53 | 54 | ## Run it! 55 | 56 | Now grab some project stats by running 57 | ``` 58 | $ ./gitlab-copy gitlab.yml 59 | ``` 60 | 61 | If everything looks good, run the same command, this time with the `-y` flag to effectively copie issues between GitLab 62 | instances (they can be the same): 63 | ``` 64 | $ ./gitlab-copy -y gitlab.yml 65 | ``` 66 | 67 | If one of the GitLab instances uses a self-signed TLS certificate, use the `-k` flag (available in `v0.8.0`) to skip the TLS verification process: 68 | 69 | ``` 70 | $ ./gitlab-copy -k -y gitlab.yml 71 | ``` 72 | 73 | ## More Features 74 | 75 | Note that a specific issue or ranges of issues can be specified in the YAML config file. If you want to 76 | copy only issue #15 and issues #20 to #30, add an `issues` key in the `from:` key: 77 | 78 | ```yaml 79 | from: 80 | url: https://gitlab.mydomain.com 81 | token: atoken 82 | project: namespace/project 83 | issues: 84 | - 15 85 | - 20-30 86 | ... 87 | ``` 88 | 89 | In order to copy all labels from one project to another (labels only, not issues), just append a `labelsOnly` 90 | entry in the `from` section: 91 | 92 | ```yaml 93 | from: 94 | url: https://gitlab.mydomain.com 95 | token: atoken 96 | project: namespace/project 97 | labelsOnly: true 98 | to: 99 | url: https://gitlab.sameorotherdomain.com 100 | token: anothertoken 101 | project: namespace/otherproject 102 | ... 103 | ``` 104 | 105 | In order to copy all milestones only, just add a `milestonesOnly` entry in the `from` section: 106 | ```yaml 107 | from: 108 | url: https://gitlab.mydomain.com 109 | token: atoken 110 | project: namespace/project 111 | milestonesOnly: true 112 | ... 113 | ``` 114 | 115 | Notes in issues can preserve original user ownership when copied. To do that, you need 116 | to 117 | 118 | - have tokens for all users involved 119 | - add related users as members of the target project beforehand (with at least a *Reporter* permission) 120 | - add a `users` entry into the `to` target section: 121 | 122 | ```yaml 123 | ... 124 | to: 125 | url: https://gitlab.sameorotherdomain.com 126 | token: anothertoken 127 | project: namespace/otherproject 128 | users: 129 | bob: anothertoken 130 | alice: herowntoken 131 | ``` 132 | 133 | ## Compile From Source 134 | 135 | Ensure you have a working [Go](https://www.golang.org) 1.18+ installation then: 136 | ``` 137 | $ go install github.com/gotsunami/gitlab-copy/cmd/gitlab-copy@latest 138 | ``` 139 | 140 | - The program gets compiled into `bin/gitlab-copy` 141 | - Cross-compile with `make buildall` 142 | - Prepare distribution packages with `make dist` 143 | 144 | ## Donate 145 | 146 | If you like this tool and want to support its development, a donation would be greatly appreciated! 147 | 148 | It's not about the amount at all: making a donation boosts the motivation to work on a project. Thank you very much if you can give anything. 149 | 150 | Monero address: `88uoutKJS2w3FfkKyJFsNwKPHzaHfTAo6LyTmHSAoQHgCkCeR8FUG4hZ8oD4fnt8iP7i1Ty72V6CLMHi1yUzLCZKHU1pB7c` 151 | 152 | ![My monero address](qr-donate.png) 153 | 154 | ## License 155 | 156 | MIT. See `LICENSE` file. 157 | -------------------------------------------------------------------------------- /cmd/gitlab-copy/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "os" 8 | "runtime" 9 | "strings" 10 | 11 | "github.com/gotsunami/gitlab-copy/config" 12 | "github.com/gotsunami/gitlab-copy/gitlab" 13 | "github.com/gotsunami/gitlab-copy/migration" 14 | "github.com/gotsunami/gitlab-copy/stats" 15 | ) 16 | 17 | func map2Human(m map[string]int) string { 18 | keys := make([]string, len(m)) 19 | i := 0 20 | for k := range m { 21 | keys[i] = k 22 | i++ 23 | } 24 | return strings.Join(keys, ", ") 25 | } 26 | 27 | func main() { 28 | flag.Usage = func() { 29 | fmt.Fprintf(os.Stderr, fmt.Sprintf("Usage: %s [options] configfile\n", os.Args[0])) 30 | fmt.Fprintf(os.Stderr, `Where configfile holds YAML looks like: 31 | from: 32 | url: https://gitlab.mydomain.com 33 | token: atoken 34 | project: namespace/project 35 | issues: 36 | - 5 37 | - 8-10 38 | ## Set labelsOnly to copy labels only, not issues 39 | # labelsOnly: true 40 | ## Move issues instead of copying them 41 | # moveIssues: true 42 | to: 43 | url: https://gitlab.myotherdomain.com 44 | token: anothertoken 45 | project: namespace/project 46 | 47 | Options: 48 | `) 49 | flag.PrintDefaults() 50 | os.Exit(2) 51 | } 52 | 53 | apply := flag.Bool("y", false, "apply migration for real") 54 | insecure := flag.Bool("k", false, "skip TLS verification process") 55 | version := flag.Bool("version", false, "") 56 | flag.Parse() 57 | 58 | if *version { 59 | fmt.Printf("Version: %s\n", config.Version) 60 | fmt.Printf("Git revision: %s\n", config.GitRev) 61 | fmt.Printf("Git branch: %s\n", config.GitBranch) 62 | fmt.Printf("Go version: %s\n", runtime.Version()) 63 | fmt.Printf("Built: %s\n", config.BuildDate) 64 | fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH) 65 | os.Exit(0) 66 | } 67 | 68 | if len(flag.Args()) != 1 { 69 | fmt.Fprint(os.Stderr, "Config file is missing.\n\n") 70 | flag.Usage() 71 | } 72 | f, err := os.Open(flag.Arg(0)) 73 | if err != nil { 74 | log.Fatal(err) 75 | } 76 | defer f.Close() 77 | c, err := config.Parse(f) 78 | if err != nil { 79 | log.Fatal(err) 80 | } 81 | 82 | if *insecure { 83 | gitlab.SkipTLSVerificationProcess() 84 | } 85 | gitlab.UseService(gitlab.NewClient()) 86 | 87 | if !*apply { 88 | fmt.Println("DUMMY MODE: won't apply anything (stats only)\n--") 89 | } 90 | 91 | m, err := migration.New(c) 92 | if err != nil { 93 | log.Fatal(err) 94 | } 95 | srcproj, err := m.SourceProject(c.SrcPrj.Name) 96 | if err != nil { 97 | log.Fatal(err) 98 | } 99 | if srcproj == nil { 100 | log.Fatalf("source project not found on %s", c.SrcPrj.ServerURL) 101 | } 102 | fmt.Printf("source: %s at %s\n", c.SrcPrj.Name, c.SrcPrj.ServerURL) 103 | 104 | dstproj, err := m.DestProject(c.DstPrj.Name) 105 | if err != nil { 106 | log.Fatal(err) 107 | } 108 | if dstproj == nil { 109 | log.Fatalf("target project not found on %s", c.DstPrj.ServerURL) 110 | } 111 | fmt.Printf("target: %s at %s\n", c.DstPrj.Name, c.DstPrj.ServerURL) 112 | fmt.Println("--") 113 | 114 | // Find out how many issues we have 115 | fmt.Printf("source: finding issues ... ") 116 | 117 | pstats := stats.NewProject(srcproj) 118 | 119 | if err := pstats.ComputeStats(m.Endpoint.SrcClient); err != nil { 120 | log.Fatal(err) 121 | } 122 | fmt.Println("OK") 123 | fmt.Printf("source: %v\n", pstats) 124 | if len(pstats.Milestones) > 0 { 125 | fmt.Printf("source: %d milestone(s): %s\n", len(pstats.Milestones), map2Human(pstats.Milestones)) 126 | } 127 | if len(pstats.Labels) > 0 { 128 | fmt.Printf("source: %d label(s): %s\n", len(pstats.Labels), map2Human(pstats.Labels)) 129 | } 130 | 131 | if !c.SrcPrj.LabelsOnly { 132 | fmt.Printf("source: counting notes (comments), can take a while ... ") 133 | if err := pstats.ComputeIssueNotes(m.Endpoint.SrcClient); err != nil { 134 | log.Fatal(err) 135 | } 136 | fmt.Printf("\rsource: %d notes%50s\n", pstats.NbNotes, " ") 137 | } 138 | fmt.Println("--") 139 | if !*apply { 140 | if c.SrcPrj.LabelsOnly { 141 | fmt.Println("Will copy labels only.") 142 | } else { 143 | if c.SrcPrj.MilestonesOnly { 144 | fmt.Println("Will copy milestones only.") 145 | } else { 146 | action := "Copy" 147 | if c.SrcPrj.MoveIssues { 148 | action = "Move" 149 | } 150 | fmt.Printf(`Those actions will be performed: 151 | - Copy milestones if not existing on target 152 | - Copy all source labels on target 153 | - %s all issues (or those specified) if not existing on target (by title) 154 | - Copy closed status on issues, if any 155 | - Set issue's assignee (if user exists) and milestone, if any 156 | - Copy notes (attached to issues) 157 | `, action) 158 | if c.SrcPrj.AutoCloseIssues { 159 | fmt.Println("- Auto-close source issues") 160 | } 161 | if c.SrcPrj.LinkToTargetIssue { 162 | fmt.Println("- Add a note with a link to new issue") 163 | fmt.Println("- Use the link text template: " + c.SrcPrj.LinkToTargetIssueText) 164 | } 165 | } 166 | } 167 | 168 | fmt.Printf("\nNow use the -y flag if that looks good to start the issues migration/label copy.\n") 169 | os.Exit(0) 170 | } 171 | 172 | if err := m.Migrate(); err != nil { 173 | log.Fatal(err) 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /cmd/gitlab-copy/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestMain(t *testing.T) { 8 | doc := ` 9 | from: 10 | token: srctoken 11 | project: srcproj 12 | to: 13 | token: dsttoken 14 | project: dstproj 15 | ` 16 | t.Log(doc) 17 | } 18 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/ioutil" 7 | 8 | "github.com/gotsunami/gitlab-copy/gitlab" 9 | "github.com/rotisserie/eris" 10 | glab "github.com/xanzy/go-gitlab" 11 | "gopkg.in/yaml.v3" 12 | ) 13 | 14 | const ( 15 | apiPath = "/api/v4" 16 | ) 17 | 18 | type issueRange struct { 19 | from, to int 20 | } 21 | 22 | // Config contains the configuration related to source and target projects. 23 | type Config struct { 24 | SrcPrj *project `yaml:"from"` 25 | DstPrj *project `yaml:"to"` 26 | } 27 | 28 | // Parse reads YAML data and returns a config suitable for later 29 | // processing. 30 | func Parse(r io.Reader) (*Config, error) { 31 | if r == nil { 32 | return nil, fmt.Errorf("nil reader") 33 | } 34 | data, err := ioutil.ReadAll(r) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | c := new(Config) 40 | if err := yaml.Unmarshal(data, c); err != nil { 41 | return nil, err 42 | } 43 | if err := c.SrcPrj.checkData("source"); err != nil { 44 | return nil, err 45 | } 46 | if err := c.DstPrj.checkData("destination"); err != nil { 47 | return nil, err 48 | } 49 | if err := c.checkUserTokens(); err != nil { 50 | return nil, err 51 | } 52 | if err := c.SrcPrj.parseIssues(); err != nil { 53 | return nil, err 54 | } 55 | if c.SrcPrj.LinkToTargetIssueText == "" { 56 | c.SrcPrj.LinkToTargetIssueText = "Closed in favor of {{.Link}}" 57 | } 58 | 59 | return c, nil 60 | } 61 | 62 | func (c *Config) checkUserTokens() error { 63 | if len(c.DstPrj.Users) == 0 { 64 | return nil 65 | } 66 | fmt.Printf("User tokens provided (for writing notes): %d\n", len(c.DstPrj.Users)) 67 | fmt.Println("Checking user tokens ... ") 68 | for user, token := range c.DstPrj.Users { 69 | g, err := gitlab.Service().WithToken(token, glab.WithBaseURL(c.DstPrj.ServerURL)) 70 | if err != nil { 71 | return eris.Wrap(err, "check user tokens") 72 | } 73 | u, _, err := g.GitLab().Users.CurrentUser() 74 | if err != nil { 75 | return eris.Wrapf(err, "Failed using the API with user %q", user) 76 | } 77 | if u.Username != user { 78 | return fmt.Errorf("Token %s matches user '%s', not '%s' as defined in the config file", token, u.Username, user) 79 | } 80 | } 81 | fmt.Println("Tokens valid and mapping to expected users\n--") 82 | return nil 83 | } 84 | -------------------------------------------------------------------------------- /config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestParseConfig(t *testing.T) { 11 | require := require.New(t) 12 | _, err := Parse(nil) 13 | require.NotNil(err) 14 | } 15 | 16 | func TestParseIssues(t *testing.T) { 17 | assert := assert.New(t) 18 | 19 | issues := []struct { 20 | name string 21 | ranges []string 22 | shouldFail bool 23 | expect []issueRange 24 | }{ 25 | {"Standalone char in range sequence", []string{"3", "5-10", "Z"}, true, nil}, 26 | {"Char in range sequence", []string{"1", "a-10"}, true, nil}, 27 | {"Malformed range", []string{"1-3-5"}, true, nil}, 28 | {"Valid range, distinct numbers", []string{"4-8"}, false, []issueRange{{4, 8}}}, 29 | {"Valid range, same number", []string{"5-5"}, false, []issueRange{{5, 5}}}, 30 | {"Valid ranges, single number", []string{"2", "5-15", "3"}, false, []issueRange{{2, 2}, {5, 15}, {3, 3}}}, 31 | {"Invalid range bounds", []string{"4-3"}, true, nil}, 32 | } 33 | 34 | p := new(project) 35 | for _, k := range issues { 36 | p.Issues = k.ranges 37 | err := p.parseIssues() 38 | if err == nil && k.shouldFail { 39 | t.Errorf("expects an error for %q, got nil", p.Issues) 40 | } 41 | if err != nil && !k.shouldFail { 42 | t.Errorf("expects no error for %q, got one: %s", p.Issues, err.Error()) 43 | } 44 | if k.expect != nil { 45 | for j, r := range k.expect { 46 | t.Run(k.name, func(t *testing.T) { 47 | assert.Equal(r.from, p.issues[j].from) 48 | assert.Equal(r.to, p.issues[j].to) 49 | }) 50 | } 51 | } 52 | } 53 | } 54 | 55 | func TestMatches(t *testing.T) { 56 | p := new(project) 57 | 58 | set := []struct { 59 | ranges []issueRange 60 | val int 61 | match bool 62 | }{ 63 | {[]issueRange{{4, 8}}, 5, true}, 64 | {[]issueRange{}, 1, true}, 65 | {[]issueRange{{4, 8}}, 9, false}, 66 | {[]issueRange{{2, 2}}, 2, true}, 67 | } 68 | for _, r := range set { 69 | p.issues = r.ranges 70 | if m := p.Matches(r.val); m != r.match { 71 | t.Errorf("expected: %v, got a match: %v", r.match, m) 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /config/project.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "path" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | type project struct { 12 | ServerURL string `yaml:"url"` 13 | Name string `yaml:"project"` 14 | Token string 15 | // Optional list of specific issues to move 16 | Issues []string 17 | // Same as Issues but converted to int by Parse 18 | issues []issueRange 19 | // If true, ignore source issues and copy labels only 20 | LabelsOnly bool `yaml:"labelsOnly"` 21 | // If true, ignore source issues and copy milestones only 22 | MilestonesOnly bool `yaml:"milestonesOnly"` 23 | // If true, move the issues (delete theme from the source project) 24 | MoveIssues bool `yaml:"moveIssues"` 25 | // Optional user tokens to write notes preserving ownership 26 | Users map[string]string `yaml:"users"` 27 | // If true, auto close source issue 28 | AutoCloseIssues bool `yaml:"autoCloseIssues"` 29 | // If true, add a link to target issue 30 | LinkToTargetIssue bool `yaml:"linkToTargetIssue"` 31 | // Optional caption to use for the link text 32 | LinkToTargetIssueText string `yaml:"linkToTargetIssueText"` 33 | } 34 | 35 | // matches checks whether issue is part of p.issues. Always 36 | // true if p.issues is an empty list, otherwise check all entries 37 | // and ranges, if any. 38 | func (p *project) Matches(issue int) bool { 39 | if len(p.issues) == 0 { 40 | return true 41 | } 42 | for _, i := range p.issues { 43 | if issue >= i.from && issue <= i.to { 44 | return true 45 | } 46 | } 47 | return false 48 | } 49 | 50 | // parseIssues ensure issue items are valid input data, i.e castable 51 | // to int, ranges allowed. 52 | func (p *project) parseIssues() error { 53 | p.issues = make([]issueRange, 0) 54 | var x [2]int 55 | for _, i := range p.Issues { 56 | vals := strings.Split(i, "-") 57 | if len(vals) > 2 { 58 | return fmt.Errorf("only one range separator allowed, '%s' not supported", vals) 59 | } 60 | if len(vals) > 1 { 61 | for k, p := range vals { 62 | num, err := strconv.ParseUint(p, 10, 64) 63 | if err != nil { 64 | return fmt.Errorf("wrong issue range in '%s': expects an integer, not '%s'", i, p) 65 | } 66 | x[k] = int(num) 67 | } 68 | if x[0] > x[1] { 69 | return fmt.Errorf("reverse range not allowed in '%s'", i) 70 | } 71 | } else { 72 | // No range 73 | num, err := strconv.ParseUint(vals[0], 10, 64) 74 | if err != nil { 75 | return fmt.Errorf("wrong issue value for '%s': expects an integer, not '%s'", i, vals[0]) 76 | } 77 | x[0] = int(num) 78 | x[1] = int(num) 79 | } 80 | p.issues = append(p.issues, issueRange{from: x[0], to: x[1]}) 81 | } 82 | return nil 83 | } 84 | 85 | func (p *project) checkData(prefix string) error { 86 | if p == nil { 87 | return fmt.Errorf("missing %s project's data", prefix) 88 | } 89 | if p.ServerURL == "" { 90 | return fmt.Errorf("missing %s project's server URL", prefix) 91 | } 92 | u, err := url.Parse(p.ServerURL) 93 | if err != nil { 94 | return err 95 | } 96 | if !strings.HasSuffix(p.ServerURL, apiPath) { 97 | p.ServerURL = path.Join(u.Host, u.Path, apiPath) 98 | p.ServerURL = fmt.Sprintf("%s://%s", u.Scheme, p.ServerURL) 99 | } 100 | if p.Name == "" { 101 | return fmt.Errorf("missing %s project's name", prefix) 102 | } 103 | if p.Token == "" { 104 | return fmt.Errorf("missing %s project's token", prefix) 105 | } 106 | return nil 107 | } 108 | -------------------------------------------------------------------------------- /config/version.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // Populated during build, don't touch! 4 | var ( 5 | Version = "undefined" 6 | GitRev = "undefined" 7 | GitBranch = "undefined" 8 | BuildDate = "undefined" 9 | ) 10 | -------------------------------------------------------------------------------- /gitlab/client.go: -------------------------------------------------------------------------------- 1 | package gitlab 2 | 3 | import ( 4 | "crypto/tls" 5 | "net/http" 6 | "net/url" 7 | 8 | "github.com/hashicorp/go-retryablehttp" 9 | "github.com/rotisserie/eris" 10 | glab "github.com/xanzy/go-gitlab" 11 | ) 12 | 13 | // client is an implementation of the GitLaber interface that makes real use 14 | // of the GitLab API. 15 | type client struct { 16 | c *glab.Client 17 | } 18 | 19 | // NewClient returns a new client. 20 | func NewClient() GitLaber { 21 | return new(client) 22 | } 23 | 24 | var skipTLSVerification bool 25 | 26 | // SkipTLSVerificationProcess skips the TLS verification process by using a custom HTTP transport. 27 | func SkipTLSVerificationProcess() { 28 | skipTLSVerification = true 29 | } 30 | 31 | // WithToken sets the token to use, along with any client options. 32 | func (c *client) WithToken(token string, options ...glab.ClientOptionFunc) (GitLaber, error) { 33 | f := new(client) 34 | 35 | if skipTLSVerification { 36 | // Setup a custom HTTP client to ignore TLS issues. 37 | tr := &http.Transport{ 38 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 39 | } 40 | hc := retryablehttp.NewClient() 41 | hc.HTTPClient.Transport = tr 42 | options = append(options, glab.WithHTTPClient(hc.StandardClient())) 43 | } 44 | 45 | p, err := glab.NewClient(token, options...) 46 | if err != nil { 47 | return nil, eris.Wrap(err, "with token") 48 | } 49 | f.c = p 50 | return f, nil 51 | } 52 | 53 | // GitLab returns the GitLab client. 54 | func (c *client) GitLab() *glab.Client { 55 | return c.c 56 | } 57 | 58 | // GetProject returns project info. 59 | func (c *client) GetProject( 60 | id interface{}, 61 | opt *glab.GetProjectOptions, 62 | options ...glab.RequestOptionFunc, 63 | ) (*glab.Project, *glab.Response, error) { 64 | return c.c.Projects.GetProject(id, opt, options...) 65 | } 66 | 67 | // CreateLabel creates a label. 68 | func (c *client) CreateLabel( 69 | id interface{}, 70 | opt *glab.CreateLabelOptions, 71 | options ...glab.RequestOptionFunc, 72 | ) (*glab.Label, *glab.Response, error) { 73 | return c.c.Labels.CreateLabel(id, opt, options...) 74 | } 75 | 76 | // ListLabels list all labels. 77 | func (c *client) ListLabels( 78 | id interface{}, 79 | opt *glab.ListLabelsOptions, 80 | options ...glab.RequestOptionFunc, 81 | ) ([]*glab.Label, *glab.Response, error) { 82 | return c.c.Labels.ListLabels(id, opt, options...) 83 | } 84 | 85 | // ListMilestones list all milestones. 86 | func (c *client) ListMilestones( 87 | id interface{}, 88 | opt *glab.ListMilestonesOptions, 89 | options ...glab.RequestOptionFunc, 90 | ) ([]*glab.Milestone, *glab.Response, error) { 91 | return c.c.Milestones.ListMilestones(id, opt, options...) 92 | } 93 | 94 | // CreateMilestone creates a milestone. 95 | func (c *client) CreateMilestone( 96 | id interface{}, 97 | opt *glab.CreateMilestoneOptions, 98 | options ...glab.RequestOptionFunc, 99 | ) (*glab.Milestone, *glab.Response, error) { 100 | return c.c.Milestones.CreateMilestone(id, opt, options...) 101 | } 102 | 103 | // UpdateMilestone updates a milestone. 104 | func (c *client) UpdateMilestone( 105 | id interface{}, 106 | milestone int, 107 | opt *glab.UpdateMilestoneOptions, 108 | options ...glab.RequestOptionFunc, 109 | ) (*glab.Milestone, *glab.Response, error) { 110 | return c.c.Milestones.UpdateMilestone(id, milestone, opt, options...) 111 | } 112 | 113 | // ListProjectIssues list all issues. 114 | func (c *client) ListProjectIssues( 115 | id interface{}, 116 | opt *glab.ListProjectIssuesOptions, 117 | options ...glab.RequestOptionFunc, 118 | ) ([]*glab.Issue, *glab.Response, error) { 119 | return c.c.Issues.ListProjectIssues(id, opt, options...) 120 | } 121 | 122 | // DeleteIssue removes an issue. 123 | func (c *client) DeleteIssue( 124 | id interface{}, 125 | issue int, 126 | options ...glab.RequestOptionFunc, 127 | ) (*glab.Response, error) { 128 | return c.c.Issues.DeleteIssue(id, issue, options...) 129 | } 130 | 131 | // GetIssue returns an issue. 132 | func (c *client) GetIssue( 133 | pid interface{}, 134 | id int, 135 | options ...glab.RequestOptionFunc, 136 | ) (*glab.Issue, *glab.Response, error) { 137 | return c.c.Issues.GetIssue(pid, id, options...) 138 | } 139 | 140 | // CreateIssue creates an issue. 141 | func (c *client) CreateIssue( 142 | pid interface{}, 143 | opt *glab.CreateIssueOptions, 144 | options ...glab.RequestOptionFunc, 145 | ) (*glab.Issue, *glab.Response, error) { 146 | return c.c.Issues.CreateIssue(pid, opt, options...) 147 | } 148 | 149 | // ListUsers lists all users. 150 | func (c *client) ListUsers( 151 | opt *glab.ListUsersOptions, 152 | opts ...glab.RequestOptionFunc, 153 | ) ([]*glab.User, *glab.Response, error) { 154 | return c.c.Users.ListUsers(opt, opts...) 155 | } 156 | 157 | // ListIssueNotes list issue notes. 158 | func (c *client) ListIssueNotes( 159 | pid interface{}, 160 | issue int, 161 | opt *glab.ListIssueNotesOptions, 162 | options ...glab.RequestOptionFunc, 163 | ) ([]*glab.Note, *glab.Response, error) { 164 | return c.c.Notes.ListIssueNotes(pid, issue, opt, options...) 165 | } 166 | 167 | // CreateIssueNote creates a note for an issue. 168 | func (c *client) CreateIssueNote( 169 | pid interface{}, 170 | issue int, 171 | opt *glab.CreateIssueNoteOptions, 172 | options ...glab.RequestOptionFunc, 173 | ) (*glab.Note, *glab.Response, error) { 174 | return c.c.Notes.CreateIssueNote(pid, issue, opt, options...) 175 | } 176 | 177 | // UpdateIssue updates an issue. 178 | func (c *client) UpdateIssue( 179 | pid interface{}, 180 | issue int, 181 | opt *glab.UpdateIssueOptions, 182 | options ...glab.RequestOptionFunc, 183 | ) (*glab.Issue, *glab.Response, error) { 184 | return c.c.Issues.UpdateIssue(pid, issue, opt, options...) 185 | } 186 | 187 | // BaseURL returns the base URL used. 188 | func (c *client) BaseURL() *url.URL { 189 | return c.c.BaseURL() 190 | } 191 | -------------------------------------------------------------------------------- /gitlab/contract.go: -------------------------------------------------------------------------------- 1 | package gitlab 2 | 3 | import ( 4 | "net/url" 5 | 6 | glab "github.com/xanzy/go-gitlab" 7 | ) 8 | 9 | var service GitLaber 10 | 11 | func UseService(c GitLaber) { 12 | service = c 13 | } 14 | 15 | func Service() GitLaber { 16 | return service 17 | } 18 | 19 | // GitLaber defines some methods of glab.Client so it can be mocked easily in 20 | // the unit tests. 21 | type GitLaber interface { 22 | WithToken(string, ...glab.ClientOptionFunc) (GitLaber, error) 23 | BaseURL() *url.URL 24 | GitLab() *glab.Client 25 | // Project 26 | GetProject(interface{}, *glab.GetProjectOptions, ...glab.RequestOptionFunc) (*glab.Project, *glab.Response, error) 27 | // Labels 28 | ListLabels(interface{}, *glab.ListLabelsOptions, ...glab.RequestOptionFunc) ([]*glab.Label, *glab.Response, error) 29 | CreateLabel(interface{}, *glab.CreateLabelOptions, ...glab.RequestOptionFunc) (*glab.Label, *glab.Response, error) 30 | // Milestones 31 | ListMilestones(interface{}, *glab.ListMilestonesOptions, ...glab.RequestOptionFunc) ([]*glab.Milestone, *glab.Response, error) 32 | CreateMilestone(interface{}, *glab.CreateMilestoneOptions, ...glab.RequestOptionFunc) (*glab.Milestone, *glab.Response, error) 33 | UpdateMilestone(interface{}, int, *glab.UpdateMilestoneOptions, ...glab.RequestOptionFunc) (*glab.Milestone, *glab.Response, error) 34 | // Issues 35 | ListProjectIssues(interface{}, *glab.ListProjectIssuesOptions, ...glab.RequestOptionFunc) ([]*glab.Issue, *glab.Response, error) 36 | GetIssue(interface{}, int, ...glab.RequestOptionFunc) (*glab.Issue, *glab.Response, error) 37 | CreateIssue(interface{}, *glab.CreateIssueOptions, ...glab.RequestOptionFunc) (*glab.Issue, *glab.Response, error) 38 | UpdateIssue(interface{}, int, *glab.UpdateIssueOptions, ...glab.RequestOptionFunc) (*glab.Issue, *glab.Response, error) 39 | DeleteIssue(interface{}, int, ...glab.RequestOptionFunc) (*glab.Response, error) 40 | // Users 41 | ListUsers(*glab.ListUsersOptions, ...glab.RequestOptionFunc) ([]*glab.User, *glab.Response, error) 42 | // Notes 43 | ListIssueNotes(interface{}, int, *glab.ListIssueNotesOptions, ...glab.RequestOptionFunc) ([]*glab.Note, *glab.Response, error) 44 | CreateIssueNote(interface{}, int, *glab.CreateIssueNoteOptions, ...glab.RequestOptionFunc) (*glab.Note, *glab.Response, error) 45 | } 46 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/gotsunami/gitlab-copy 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/hashicorp/go-retryablehttp v0.7.2 7 | github.com/rotisserie/eris v0.5.4 8 | github.com/stretchr/testify v1.8.2 9 | github.com/xanzy/go-gitlab v0.80.2 10 | gopkg.in/yaml.v3 v3.0.1 11 | ) 12 | 13 | require ( 14 | github.com/davecgh/go-spew v1.1.1 // indirect 15 | github.com/golang/protobuf v1.5.2 // indirect 16 | github.com/google/go-querystring v1.1.0 // indirect 17 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 18 | github.com/pmezard/go-difflib v1.0.0 // indirect 19 | golang.org/x/net v0.7.0 // indirect 20 | golang.org/x/oauth2 v0.5.0 // indirect 21 | golang.org/x/time v0.3.0 // indirect 22 | google.golang.org/appengine v1.6.7 // indirect 23 | google.golang.org/protobuf v1.28.1 // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 5 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 6 | github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= 7 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 8 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 9 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 10 | github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= 11 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 12 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 13 | github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 14 | github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 15 | github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= 16 | github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= 17 | github.com/hashicorp/go-retryablehttp v0.7.2 h1:AcYqCvkpalPnPF2pn0KamgwamS42TqUDDYFRKq/RAd0= 18 | github.com/hashicorp/go-retryablehttp v0.7.2/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= 19 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 20 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 21 | github.com/rotisserie/eris v0.5.4 h1:Il6IvLdAapsMhvuOahHWiBnl1G++Q0/L5UIkI5mARSk= 22 | github.com/rotisserie/eris v0.5.4/go.mod h1:Z/kgYTJiJtocxCbFfvRmO+QejApzG6zpyky9G1A4g9s= 23 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 24 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 25 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 26 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 27 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 28 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 29 | github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= 30 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 31 | github.com/xanzy/go-gitlab v0.80.2 h1:CH1Q7NDklqZllox4ICVF4PwlhQGfPtE+w08Jsb74ZX0= 32 | github.com/xanzy/go-gitlab v0.80.2/go.mod h1:DlByVTSXhPsJMYL6+cm8e8fTJjeBmhrXdC/yvkKKt6M= 33 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 34 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 35 | golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= 36 | golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 37 | golang.org/x/oauth2 v0.5.0 h1:HuArIo48skDwlrvM3sEdHXElYslAMsf3KwRkkW4MC4s= 38 | golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I= 39 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 40 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 41 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 42 | golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= 43 | golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 44 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 45 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 46 | google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= 47 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 48 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 49 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 50 | google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= 51 | google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 52 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 53 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 54 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 55 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 56 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 57 | -------------------------------------------------------------------------------- /migration/client_test.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/url" 7 | 8 | "github.com/gotsunami/gitlab-copy/gitlab" 9 | glab "github.com/xanzy/go-gitlab" 10 | ) 11 | 12 | type fakeClient struct { 13 | baseURL *url.URL 14 | errors struct { 15 | createIssue, createIssueNote, createLabel, createMilestone error 16 | deleteIssue error 17 | getIssue, getProject error 18 | listIssueNotes, listLabels, listMilestones, listProjetIssues error 19 | listUsers error 20 | updateIssue, updateMilestone error 21 | baseURL error 22 | } 23 | labels []*glab.Label 24 | milestones []*glab.Milestone 25 | users []*glab.User 26 | issues []*glab.Issue 27 | issueNotes []*glab.Note 28 | exitPagination bool 29 | httpErrorRaiseURITooLong bool 30 | } 31 | 32 | // New fake GitLab client, for the UT. 33 | func NewFakeClient(token string) gitlab.GitLaber { 34 | return new(fakeClient) 35 | } 36 | 37 | func (c *fakeClient) WithToken(token string, options ...glab.ClientOptionFunc) (gitlab.GitLaber, error) { 38 | return new(fakeClient), nil 39 | } 40 | 41 | func (c *fakeClient) BaseURL() *url.URL { 42 | return c.baseURL 43 | } 44 | 45 | func (c *fakeClient) GitLab() *glab.Client { 46 | return nil 47 | } 48 | 49 | func (c *fakeClient) GetProject(id interface{}, opt *glab.GetProjectOptions, options ...glab.RequestOptionFunc) (*glab.Project, *glab.Response, error) { 50 | err := c.errors.getProject 51 | if err != nil { 52 | return nil, nil, err 53 | } 54 | p := new(glab.Project) 55 | p.Name = "A name" 56 | r := &glab.Response{ 57 | Response: new(http.Response), 58 | } 59 | r.StatusCode = http.StatusOK 60 | return p, r, nil 61 | } 62 | 63 | func (c *fakeClient) CreateLabel(id interface{}, opt *glab.CreateLabelOptions, options ...glab.RequestOptionFunc) (*glab.Label, *glab.Response, error) { 64 | r := &glab.Response{ 65 | Response: new(http.Response), 66 | } 67 | err := c.errors.createLabel 68 | if err != nil { 69 | r.StatusCode = http.StatusBadRequest 70 | return nil, r, err 71 | } 72 | r.StatusCode = http.StatusOK 73 | for _, l := range c.labels { 74 | if l.Name == *opt.Name { 75 | return nil, nil, fmt.Errorf("label %q already exists", l.Name) 76 | } 77 | } 78 | l := &glab.Label{ 79 | Name: *opt.Name, 80 | Color: *opt.Color, 81 | Description: *opt.Description, 82 | } 83 | c.labels = append(c.labels, l) 84 | return l, r, nil 85 | } 86 | 87 | func (c *fakeClient) ListLabels(id interface{}, opt *glab.ListLabelsOptions, options ...glab.RequestOptionFunc) ([]*glab.Label, *glab.Response, error) { 88 | err := c.errors.listLabels 89 | if err != nil { 90 | return nil, nil, err 91 | } 92 | return c.labels, nil, nil 93 | } 94 | 95 | func (c *fakeClient) ListMilestones(id interface{}, opt *glab.ListMilestonesOptions, options ...glab.RequestOptionFunc) ([]*glab.Milestone, *glab.Response, error) { 96 | err := c.errors.listMilestones 97 | if err != nil { 98 | return nil, nil, err 99 | } 100 | return c.milestones, nil, nil 101 | } 102 | 103 | func (c *fakeClient) CreateMilestone(id interface{}, opt *glab.CreateMilestoneOptions, options ...glab.RequestOptionFunc) (*glab.Milestone, *glab.Response, error) { 104 | err := c.errors.createMilestone 105 | if err != nil { 106 | return nil, nil, err 107 | } 108 | m := &glab.Milestone{ 109 | ID: len(c.milestones), 110 | Title: *opt.Title, 111 | } 112 | for _, p := range c.milestones { 113 | if p.Title == m.Title { 114 | return nil, nil, fmt.Errorf("milestone %q already exists", p.Title) 115 | } 116 | } 117 | c.milestones = append(c.milestones, m) 118 | return m, nil, nil 119 | } 120 | 121 | func (c *fakeClient) UpdateMilestone( 122 | id interface{}, 123 | milestone int, 124 | opt *glab.UpdateMilestoneOptions, 125 | options ...glab.RequestOptionFunc, 126 | ) (*glab.Milestone, *glab.Response, error) { 127 | err := c.errors.updateMilestone 128 | if err != nil { 129 | return nil, nil, err 130 | } 131 | m := c.milestones[id.(int)] 132 | m.State = *opt.StateEvent 133 | return m, nil, nil 134 | } 135 | 136 | func (c *fakeClient) ListProjectIssues( 137 | id interface{}, 138 | opt *glab.ListProjectIssuesOptions, 139 | options ...glab.RequestOptionFunc, 140 | ) ([]*glab.Issue, *glab.Response, error) { 141 | err := c.errors.listProjetIssues 142 | if err != nil { 143 | return nil, nil, err 144 | } 145 | if opt != nil && opt.ListOptions.Page > 1 { 146 | // No more pages. End of pagination. 147 | return nil, nil, nil 148 | } 149 | return c.issues, nil, nil 150 | } 151 | 152 | func (c *fakeClient) DeleteIssue(interface{}, int, ...glab.RequestOptionFunc) (*glab.Response, error) { 153 | err := c.errors.deleteIssue 154 | if err != nil { 155 | return nil, err 156 | } 157 | return nil, nil 158 | } 159 | 160 | func (c *fakeClient) GetIssue(pid interface{}, id int, options ...glab.RequestOptionFunc) (*glab.Issue, *glab.Response, error) { 161 | err := c.errors.getIssue 162 | if err != nil { 163 | return nil, nil, err 164 | } 165 | return c.issues[id], nil, nil 166 | } 167 | 168 | func (c *fakeClient) CreateIssue(pid interface{}, opt *glab.CreateIssueOptions, options ...glab.RequestOptionFunc) (*glab.Issue, *glab.Response, error) { 169 | err := c.errors.createIssue 170 | if err != nil { 171 | if c.httpErrorRaiseURITooLong { 172 | r := &glab.Response{ 173 | Response: new(http.Response), 174 | } 175 | r.Response.StatusCode = http.StatusRequestURITooLong 176 | return nil, r, err 177 | } 178 | return nil, nil, err 179 | } 180 | i := &glab.Issue{ 181 | ID: len(c.issues), 182 | Title: *opt.Title, 183 | Assignee: &glab.IssueAssignee{}, 184 | } 185 | if opt.AssigneeIDs != nil && len(*opt.AssigneeIDs) > 0 { 186 | i.Assignee.Username = "mat" 187 | } 188 | for _, p := range c.issues { 189 | if p.Title == i.Title { 190 | return nil, nil, fmt.Errorf("issue %q already exists", p.Title) 191 | } 192 | } 193 | c.issues = append(c.issues, i) 194 | return i, nil, nil 195 | } 196 | 197 | func (c *fakeClient) ListUsers(opt *glab.ListUsersOptions, opts ...glab.RequestOptionFunc) ([]*glab.User, *glab.Response, error) { 198 | err := c.errors.listUsers 199 | if err != nil { 200 | return nil, nil, err 201 | } 202 | return c.users, nil, nil 203 | } 204 | 205 | func (c *fakeClient) ListIssueNotes( 206 | pid interface{}, 207 | issue int, 208 | opt *glab.ListIssueNotesOptions, 209 | options ...glab.RequestOptionFunc, 210 | ) ([]*glab.Note, *glab.Response, error) { 211 | err := c.errors.listIssueNotes 212 | if err != nil { 213 | return nil, nil, err 214 | } 215 | return c.issueNotes, nil, nil 216 | } 217 | 218 | func (c *fakeClient) Client() *glab.Client { 219 | return nil 220 | } 221 | func (c *fakeClient) CreateIssueNote( 222 | pid interface{}, 223 | issue int, 224 | opt *glab.CreateIssueNoteOptions, 225 | options ...glab.RequestOptionFunc, 226 | ) (*glab.Note, *glab.Response, error) { 227 | err := c.errors.createIssueNote 228 | if err != nil { 229 | r := &glab.Response{ 230 | Response: new(http.Response), 231 | } 232 | if c.httpErrorRaiseURITooLong { 233 | r.Response.StatusCode = http.StatusRequestURITooLong 234 | } 235 | return nil, r, err 236 | } 237 | return nil, nil, nil 238 | } 239 | 240 | func (c *fakeClient) UpdateIssue(interface{}, int, *glab.UpdateIssueOptions, ...glab.RequestOptionFunc) (*glab.Issue, *glab.Response, error) { 241 | err := c.errors.updateIssue 242 | if err != nil { 243 | return nil, nil, err 244 | } 245 | return nil, nil, nil 246 | } 247 | 248 | func (c *fakeClient) clearMilestones() { 249 | c.milestones = nil 250 | c.milestones = make([]*glab.Milestone, 0) 251 | } 252 | 253 | func (c *fakeClient) clearLabels() { 254 | c.labels = nil 255 | c.labels = make([]*glab.Label, 0) 256 | } 257 | 258 | func (c *fakeClient) clearIssues() { 259 | c.issues = nil 260 | c.issues = make([]*glab.Issue, 0) 261 | } 262 | -------------------------------------------------------------------------------- /migration/configs_test.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | const cfg1 = ` 4 | from: 5 | url: https://gitlab.mydomain.com 6 | token: sourcetoken 7 | project: source/project 8 | # issues: 9 | # - 5 10 | # - 8-10 11 | labelsOnly: true 12 | # moveIssues: true 13 | to: 14 | url: https://gitlab.mydomain.com 15 | token: desttoken 16 | project: dest/project 17 | ` 18 | 19 | const cfg2 = ` 20 | from: 21 | url: https://gitlab.mydomain.com 22 | token: sourcetoken 23 | project: source/project 24 | to: 25 | url: https://gitlab.mydomain.com 26 | token: desttoken 27 | project: dest/project 28 | ` 29 | 30 | const cfg3 = ` 31 | from: 32 | url: https://gitlab.mydomain.com 33 | token: sourcetoken 34 | project: source/project 35 | milestonesOnly: true 36 | to: 37 | url: https://gitlab.mydomain.com 38 | token: desttoken 39 | project: dest/project 40 | ` 41 | 42 | const cfg4 = ` 43 | from: 44 | url: https://gitlab.mydomain.com 45 | token: sourcetoken 46 | project: source/project 47 | moveIssues: true 48 | to: 49 | url: https://gitlab.mydomain.com 50 | token: desttoken 51 | project: dest/project 52 | ` 53 | -------------------------------------------------------------------------------- /migration/issue.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "sort" 10 | "text/template" 11 | "time" 12 | 13 | "github.com/gotsunami/gitlab-copy/config" 14 | "github.com/gotsunami/gitlab-copy/gitlab" 15 | "github.com/rotisserie/eris" 16 | glab "github.com/xanzy/go-gitlab" 17 | ) 18 | 19 | var ( 20 | errDuplicateIssue = errors.New("Duplicate Issue") 21 | ) 22 | 23 | const ( 24 | // ResultsPerPage is the Number of results per page. 25 | ResultsPerPage = 100 26 | ) 27 | 28 | // Endpoint refers to the GitLab server endpoints. 29 | type Endpoint struct { 30 | SrcClient, DstClient gitlab.GitLaber 31 | } 32 | 33 | // Migration defines a migration step. 34 | type Migration struct { 35 | params *config.Config 36 | Endpoint *Endpoint 37 | srcProject, dstProject *glab.Project 38 | toUsers map[string]gitlab.GitLaber 39 | skipIssue bool 40 | } 41 | 42 | // New creates a new migration. 43 | func New(c *config.Config) (*Migration, error) { 44 | if c == nil { 45 | return nil, errors.New("nil params") 46 | } 47 | m := &Migration{params: c} 48 | m.toUsers = make(map[string]gitlab.GitLaber) 49 | 50 | fromgl, err := gitlab.Service().WithToken( 51 | c.SrcPrj.Token, 52 | glab.WithBaseURL(c.SrcPrj.ServerURL), 53 | ) 54 | if err != nil { 55 | return nil, eris.Wrap(err, "migration: src token") 56 | } 57 | togl, err := gitlab.Service().WithToken( 58 | c.DstPrj.Token, 59 | glab.WithBaseURL(c.DstPrj.ServerURL), 60 | ) 61 | if err != nil { 62 | return nil, eris.Wrap(err, "migration: dst token") 63 | } 64 | for user, token := range c.DstPrj.Users { 65 | uc, err := gitlab.Service().WithToken(token, glab.WithBaseURL(c.DstPrj.ServerURL)) 66 | if err != nil { 67 | return nil, eris.Wrap(err, "migration: dst users check") 68 | } 69 | m.toUsers[user] = uc 70 | } 71 | m.Endpoint = &Endpoint{fromgl, togl} 72 | return m, nil 73 | } 74 | 75 | // Returns project by name. 76 | func (m *Migration) project(endpoint gitlab.GitLaber, name, which string) (*glab.Project, error) { 77 | proj, resp, err := endpoint.GetProject(name, nil) 78 | if resp == nil { 79 | return nil, eris.Wrap(err, "get project") 80 | } 81 | if resp.StatusCode == http.StatusNotFound { 82 | return nil, fmt.Errorf("%s project '%s' not found", which, name) 83 | } 84 | if err != nil { 85 | return nil, err 86 | } 87 | return proj, nil 88 | } 89 | 90 | func (m *Migration) SourceProject(name string) (*glab.Project, error) { 91 | p, err := m.project(m.Endpoint.SrcClient, name, "source") 92 | if err != nil { 93 | return nil, err 94 | } 95 | m.srcProject = p 96 | return p, nil 97 | } 98 | 99 | func (m *Migration) DestProject(name string) (*glab.Project, error) { 100 | p, err := m.project(m.Endpoint.DstClient, name, "target") 101 | if err != nil { 102 | return nil, err 103 | } 104 | m.dstProject = p 105 | return p, nil 106 | } 107 | 108 | func (m *Migration) migrateIssue(issueID int) error { 109 | source := m.Endpoint.SrcClient 110 | target := m.Endpoint.DstClient 111 | 112 | srcProjectID := m.srcProject.ID 113 | tarProjectID := m.dstProject.ID 114 | 115 | issue, _, err := source.GetIssue(srcProjectID, issueID) 116 | if err != nil { 117 | return fmt.Errorf("target: can't fetch issue: %s", err.Error()) 118 | } 119 | tis, _, err := target.ListProjectIssues(tarProjectID, nil) 120 | if err != nil { 121 | return fmt.Errorf("target: can't fetch issue: %s", err.Error()) 122 | } 123 | for _, t := range tis { 124 | if issue.Title == t.Title { 125 | // Target issue already exists, let's skip this one. 126 | return errDuplicateIssue 127 | } 128 | } 129 | labels := make(glab.Labels, 0) 130 | iopts := &glab.CreateIssueOptions{ 131 | Title: &issue.Title, 132 | Description: &issue.Description, 133 | Labels: &labels, 134 | } 135 | if issue.Assignee != nil && issue.Assignee.Username != "" { 136 | // Assigned, does target user exist? 137 | // User may have a different ID on target 138 | users, _, err := target.ListUsers(nil) 139 | if err == nil { 140 | for _, u := range users { 141 | if u.Username == issue.Assignee.Username { 142 | iopts.AssigneeIDs = &[]int{u.ID} 143 | break 144 | } 145 | } 146 | } else { 147 | return fmt.Errorf("target: error fetching users: %v", err) 148 | } 149 | } 150 | if issue.Milestone != nil && issue.Milestone.Title != "" { 151 | miles, _, err := target.ListMilestones(tarProjectID, nil) 152 | if err == nil { 153 | found := false 154 | for _, mi := range miles { 155 | found = false 156 | if mi.Title == issue.Milestone.Title { 157 | found = true 158 | iopts.MilestoneID = &mi.ID 159 | break 160 | } 161 | } 162 | if !found { 163 | // Create target milestone 164 | cmopts := &glab.CreateMilestoneOptions{ 165 | Title: &issue.Milestone.Title, 166 | Description: &issue.Milestone.Description, 167 | DueDate: issue.Milestone.DueDate, 168 | } 169 | mi, _, err := target.CreateMilestone(tarProjectID, cmopts) 170 | if err == nil { 171 | iopts.MilestoneID = &mi.ID 172 | } else { 173 | return fmt.Errorf("target: error creating milestone '%s': %s", issue.Milestone.Title, err.Error()) 174 | } 175 | } 176 | } else { 177 | return fmt.Errorf("target: error listing milestones: %s", err.Error()) 178 | } 179 | } 180 | // Copy existing labels. 181 | for _, label := range issue.Labels { 182 | *iopts.Labels = append(*iopts.Labels, label) 183 | } 184 | // Create target issue if not existing (same name). 185 | ni, resp, err := target.CreateIssue(tarProjectID, iopts) 186 | if err != nil { 187 | if resp != nil && resp.StatusCode == http.StatusRequestURITooLong { 188 | fmt.Printf("target: caught a %q error, shortening issue's decription length ...\n", http.StatusText(resp.StatusCode)) 189 | if len(*iopts.Description) == 0 { 190 | return fmt.Errorf("target: error creating issue: no description but %q error", http.StatusText(resp.StatusCode)) 191 | } 192 | smalld := (*iopts.Description)[:1024] 193 | iopts.Description = &smalld 194 | ni, _, err = target.CreateIssue(tarProjectID, iopts) 195 | if err != nil { 196 | return fmt.Errorf("target: error creating empty issue: %s", err.Error()) 197 | } 198 | } else { 199 | return fmt.Errorf("target: error creating issue: %s", err.Error()) 200 | } 201 | } 202 | 203 | // Copy related notes (comments) 204 | notes, _, err := source.ListIssueNotes(srcProjectID, issue.IID, nil) 205 | if err != nil { 206 | return fmt.Errorf("source: can't get issue #%d notes: %s", issue.IID, err.Error()) 207 | } 208 | opts := &glab.CreateIssueNoteOptions{} 209 | // Notes on target will be added in reverse order. 210 | for j := len(notes) - 1; j >= 0; j-- { 211 | n := notes[j] 212 | target = m.Endpoint.DstClient 213 | // Can we write the comment with user ownership? 214 | if _, ok := m.toUsers[n.Author.Username]; ok { 215 | target = m.toUsers[n.Author.Username] 216 | opts.Body = &n.Body 217 | } else { 218 | // Nope. Let's add a header note instead. 219 | head := fmt.Sprintf("%s @%s wrote on %s :", n.Author.Name, n.Author.Username, n.CreatedAt.Format(time.RFC1123)) 220 | bd := fmt.Sprintf("%s\n\n%s", head, n.Body) 221 | opts.Body = &bd 222 | } 223 | _, resp, err := target.CreateIssueNote(tarProjectID, ni.IID, opts) 224 | if err != nil { 225 | if resp.StatusCode == http.StatusRequestURITooLong { 226 | fmt.Printf("target: note's body too long, shortening it ...\n") 227 | if len(*opts.Body) > 1024 { 228 | smallb := (*opts.Body)[:1024] 229 | opts.Body = &smallb 230 | } 231 | _, _, err := target.CreateIssueNote(tarProjectID, ni.ID, opts) 232 | if err != nil { 233 | return fmt.Errorf("target: error creating note (with shorter body) for issue #%d: %s", ni.IID, err.Error()) 234 | } 235 | } else { 236 | return fmt.Errorf("target: error creating note for issue #%d: %s", ni.IID, err.Error()) 237 | } 238 | } 239 | } 240 | target = m.Endpoint.DstClient 241 | 242 | if issue.State == "closed" { 243 | event := "close" 244 | _, _, err := target.UpdateIssue(tarProjectID, ni.IID, 245 | &glab.UpdateIssueOptions{StateEvent: &event, Labels: &issue.Labels}) 246 | if err != nil { 247 | return fmt.Errorf("target: error closing issue #%d: %s", ni.IID, err.Error()) 248 | } 249 | } 250 | // Add a link to target issue if needed 251 | if m.params.SrcPrj.LinkToTargetIssue { 252 | var dstProjectURL string 253 | // Strip URL if moving on the same GitLab installation. 254 | if m.Endpoint.SrcClient.BaseURL().Host == m.Endpoint.DstClient.BaseURL().Host { 255 | dstProjectURL = m.dstProject.PathWithNamespace 256 | } else { 257 | dstProjectURL = m.dstProject.WebURL 258 | } 259 | tmpl, err := template.New("link").Parse(m.params.SrcPrj.LinkToTargetIssueText) 260 | if err != nil { 261 | return fmt.Errorf("link to target issue: error parsing linkToTargetIssueText parameter: %s", err.Error()) 262 | } 263 | noteLink := fmt.Sprintf("%s#%d", dstProjectURL, ni.IID) 264 | type link struct { 265 | Link string 266 | } 267 | buf := new(bytes.Buffer) 268 | if err := tmpl.Execute(buf, &link{noteLink}); err != nil { 269 | return fmt.Errorf("link to target issue: %s", err.Error()) 270 | } 271 | nopt := buf.String() 272 | opts := &glab.CreateIssueNoteOptions{ 273 | Body: &nopt, 274 | } 275 | _, _, err = target.CreateIssueNote(srcProjectID, issue.IID, opts) 276 | if err != nil { 277 | return fmt.Errorf("source: error adding closing note for issue #%d: %s", issue.IID, err.Error()) 278 | } 279 | } 280 | // Auto close source issue if needed 281 | if m.params.SrcPrj.AutoCloseIssues { 282 | event := "close" 283 | _, _, err := source.UpdateIssue(srcProjectID, issue.ID, 284 | &glab.UpdateIssueOptions{StateEvent: &event, Labels: &issue.Labels}) 285 | if err != nil { 286 | return fmt.Errorf("source: error closing issue #%d: %s", issue.IID, err.Error()) 287 | } 288 | } 289 | 290 | fmt.Printf("target: created issue #%d: %s [%s]\n", ni.IID, ni.Title, issue.State) 291 | return nil 292 | } 293 | 294 | type issueID struct { 295 | IID, ID int 296 | } 297 | 298 | type byIID []issueID 299 | 300 | func (a byIID) Len() int { return len(a) } 301 | func (a byIID) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 302 | func (a byIID) Less(i, j int) bool { return a[i].IID < a[j].IID } 303 | 304 | // Migrate performs the issues migration. 305 | func (m *Migration) Migrate() error { 306 | _, err := m.SourceProject(m.params.SrcPrj.Name) 307 | if err != nil { 308 | return eris.Wrap(err, "migrate") 309 | } 310 | _, err = m.DestProject(m.params.DstPrj.Name) 311 | if err != nil { 312 | return eris.Wrap(err, "migrate") 313 | } 314 | 315 | source := m.Endpoint.SrcClient 316 | target := m.Endpoint.DstClient 317 | 318 | srcProjectID := m.srcProject.ID 319 | tarProjectID := m.dstProject.ID 320 | 321 | curPage := 1 322 | optSort := "asc" 323 | opts := &glab.ListProjectIssuesOptions{Sort: &optSort, ListOptions: glab.ListOptions{PerPage: ResultsPerPage, Page: curPage}} 324 | 325 | s := make([]issueID, 0) 326 | 327 | // Copy all source labels on target 328 | labels, _, err := source.ListLabels(srcProjectID, nil) 329 | if err != nil { 330 | return fmt.Errorf("source: can't fetch labels: %s", err.Error()) 331 | } 332 | fmt.Printf("Found %d labels ...\n", len(labels)) 333 | for _, label := range labels { 334 | clopts := &glab.CreateLabelOptions{Name: &label.Name, Color: &label.Color, Description: &label.Description} 335 | _, resp, err := target.CreateLabel(tarProjectID, clopts) 336 | if err != nil { 337 | // GitLab returns a 409 code if label already exists 338 | if resp.StatusCode != http.StatusConflict { 339 | return fmt.Errorf("target: error creating label '%s': %s", label, err.Error()) 340 | } 341 | } 342 | } 343 | 344 | if m.params.SrcPrj.LabelsOnly { 345 | // We're done here 346 | return nil 347 | } 348 | 349 | if m.params.SrcPrj.MilestonesOnly { 350 | fmt.Println("Copying milestones ...") 351 | miles, _, err := source.ListMilestones(srcProjectID, nil) 352 | if err != nil { 353 | return fmt.Errorf("error getting the milestones from source project: %s", err.Error()) 354 | } 355 | fmt.Printf("Found %d milestones\n", len(miles)) 356 | for _, mi := range miles { 357 | // Create target milestone 358 | cmopts := &glab.CreateMilestoneOptions{ 359 | Title: &mi.Title, 360 | Description: &mi.Description, 361 | DueDate: mi.DueDate, 362 | } 363 | tmi, _, err := target.CreateMilestone(tarProjectID, cmopts) 364 | if err != nil { 365 | return fmt.Errorf("target: error creating milestone '%s': %s", mi.Title, err.Error()) 366 | } 367 | if mi.State == "closed" { 368 | event := "close" 369 | umopts := &glab.UpdateMilestoneOptions{ 370 | StateEvent: &event, 371 | } 372 | _, _, err := target.UpdateMilestone(tarProjectID, tmi.ID, umopts) 373 | if err != nil { 374 | return fmt.Errorf("target: error closing milestone '%s': %s", mi.Title, err.Error()) 375 | } 376 | } 377 | } 378 | // We're done here 379 | return nil 380 | } 381 | 382 | fmt.Println("Copying issues ...") 383 | 384 | // First, count issues 385 | for { 386 | issues, _, err := source.ListProjectIssues(srcProjectID, opts) 387 | if err != nil { 388 | return err 389 | } 390 | if len(issues) == 0 { 391 | break 392 | } 393 | 394 | for _, issue := range issues { 395 | s = append(s, issueID{IID: issue.IID, ID: issue.ID}) 396 | } 397 | curPage++ 398 | opts.Page = curPage 399 | } 400 | 401 | // Then sort 402 | sort.Sort(byIID(s)) 403 | 404 | for _, issue := range s { 405 | if m.params.SrcPrj.Matches(issue.IID) { 406 | if err := m.migrateIssue(issue.IID); err != nil { 407 | if err == errDuplicateIssue { 408 | fmt.Printf("target: issue %d already exists, skipping...", issue.IID) 409 | continue 410 | } 411 | return err 412 | } 413 | if m.params.SrcPrj.MoveIssues { 414 | // Delete issue from source project 415 | _, err := source.DeleteIssue(srcProjectID, issue.ID) 416 | if err != nil { 417 | log.Printf("could not delete the issue %d: %s", issue.ID, err.Error()) 418 | } 419 | } 420 | } 421 | } 422 | 423 | return nil 424 | } 425 | -------------------------------------------------------------------------------- /migration/issue_test.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "github.com/gotsunami/gitlab-copy/config" 11 | "github.com/gotsunami/gitlab-copy/gitlab" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | glab "github.com/xanzy/go-gitlab" 15 | ) 16 | 17 | func init() { 18 | gitlab.UseService(new(fakeClient)) 19 | } 20 | 21 | func source(m *Migration) *fakeClient { 22 | return m.Endpoint.SrcClient.(*fakeClient) 23 | } 24 | 25 | func dest(m *Migration) *fakeClient { 26 | return m.Endpoint.DstClient.(*fakeClient) 27 | } 28 | 29 | func TestMigrate(t *testing.T) { 30 | assert := assert.New(t) 31 | require := require.New(t) 32 | 33 | m, err := New(nil) 34 | assert.Error(err) 35 | 36 | runs := []struct { 37 | name string // Sub-test name 38 | config string // YAML config 39 | setup func(src, dst *fakeClient) // Defines any option before calling Migrate() 40 | asserts func(err error, src, dst *fakeClient) 41 | }{ 42 | { 43 | "SourceProject returns an error", 44 | cfg1, 45 | func(src, dst *fakeClient) { 46 | src.errors.getProject = errors.New("err") 47 | }, 48 | func(err error, src, dst *fakeClient) { 49 | assert.Error(err) 50 | src.errors.getProject = nil 51 | }, 52 | }, 53 | { 54 | "copy 2 labels only", 55 | cfg1, 56 | func(src, dst *fakeClient) { 57 | src.labels = makeLabels("bug", "doc") 58 | }, 59 | func(err error, src, dst *fakeClient) { 60 | require.NoError(err) 61 | if assert.Equal(2, len(dst.labels)) { 62 | assert.Equal("bug", dst.labels[0].Name) 63 | assert.Equal("doc", dst.labels[1].Name) 64 | } 65 | }, 66 | }, 67 | { 68 | "copy 1 label and 2 issues", 69 | cfg2, 70 | func(src, dst *fakeClient) { 71 | src.labels = makeLabels("P0") 72 | }, 73 | func(err error, src, dst *fakeClient) { 74 | require.NoError(err) 75 | if assert.Equal(1, len(dst.labels)) { 76 | assert.Equal("P0", dst.labels[0].Name) 77 | } 78 | }, 79 | }, 80 | { 81 | "copy milestones only", 82 | cfg3, 83 | func(src, dst *fakeClient) { 84 | src.clearMilestones() 85 | src.milestones = makeMilestones("v1", "v2") 86 | }, 87 | func(err error, src, dst *fakeClient) { 88 | require.NoError(err) 89 | if assert.Equal(2, len(dst.milestones)) { 90 | assert.Equal("v1", dst.milestones[0].Title) 91 | assert.Equal("v2", dst.milestones[1].Title) 92 | } 93 | }, 94 | }, 95 | { 96 | "copy milestones only, error listing milestones", 97 | cfg3, 98 | func(src, dst *fakeClient) { 99 | src.clearMilestones() 100 | src.milestones = makeMilestones("v1") 101 | src.errors.listMilestones = errors.New("err") 102 | }, 103 | func(err error, src, dst *fakeClient) { 104 | assert.Error(err) 105 | src.errors.listMilestones = nil 106 | }, 107 | }, 108 | { 109 | "copy milestones only, error creating milestones", 110 | cfg3, 111 | func(src, dst *fakeClient) { 112 | src.clearMilestones() 113 | src.milestones = makeMilestones("v1") 114 | dst.errors.createMilestone = errors.New("err") 115 | }, 116 | func(err error, src, dst *fakeClient) { 117 | assert.Error(err) 118 | dst.errors.createMilestone = nil 119 | }, 120 | }, 121 | { 122 | "list labels fails", 123 | cfg3, 124 | func(src, dst *fakeClient) { 125 | src.errors.listLabels = errors.New("err") 126 | }, 127 | func(err error, src, dst *fakeClient) { 128 | assert.Error(err) 129 | src.errors.listLabels = nil 130 | }, 131 | }, 132 | { 133 | "create labels fails", 134 | cfg3, 135 | func(src, dst *fakeClient) { 136 | src.clearLabels() 137 | src.labels = makeLabels("P0") 138 | dst.errors.createLabel = errors.New("err") 139 | }, 140 | func(err error, src, dst *fakeClient) { 141 | require.Error(err) 142 | dst.errors.createLabel = nil 143 | }, 144 | }, 145 | { 146 | "copy milestone only state closed", 147 | cfg3, 148 | func(src, dst *fakeClient) { 149 | src.clearMilestones() 150 | src.milestones = makeMilestones("v1") 151 | src.milestones[0].State = "closed" 152 | }, 153 | func(err error, src, dst *fakeClient) { 154 | require.NoError(err) 155 | if assert.Equal(1, len(dst.milestones)) { 156 | assert.Equal("close", dst.milestones[0].State) 157 | } 158 | }, 159 | }, 160 | { 161 | "copy closed milestone fails", 162 | cfg3, 163 | func(src, dst *fakeClient) { 164 | src.clearMilestones() 165 | src.milestones = makeMilestones("v1") 166 | src.milestones[0].State = "closed" 167 | dst.errors.updateMilestone = errors.New("err") 168 | }, 169 | func(err error, src, dst *fakeClient) { 170 | require.Error(err) 171 | dst.errors.updateMilestone = nil 172 | }, 173 | }, 174 | { 175 | "copy 1 issue", 176 | cfg2, 177 | func(src, dst *fakeClient) { 178 | src.issues = makeIssues("issue1") 179 | }, 180 | func(err error, src, dst *fakeClient) { 181 | require.NoError(err) 182 | if assert.Len(dst.issues, 1) { 183 | assert.Equal("issue1", dst.issues[0].Title) 184 | } 185 | }, 186 | }, 187 | { 188 | "copy 1 issue with error", 189 | cfg2, 190 | func(src, dst *fakeClient) { 191 | src.issues = makeIssues("issue1") 192 | src.errors.listProjetIssues = errors.New("err") 193 | }, 194 | func(err error, src, dst *fakeClient) { 195 | require.Error(err) 196 | }, 197 | }, 198 | { 199 | "No fatal error if duplicate issue", 200 | cfg2, 201 | func(src, dst *fakeClient) { 202 | src.issues = makeIssues("issue1") 203 | dst.issues = makeIssues("issue1") 204 | }, 205 | func(err error, src, dst *fakeClient) { 206 | // No error since we don't want the program to exit. 207 | assert.NoError(err) 208 | }, 209 | }, 210 | { 211 | "Move issue", 212 | cfg4, 213 | func(src, dst *fakeClient) { 214 | src.issues = makeIssues("issue1") 215 | }, 216 | func(err error, src, dst *fakeClient) { 217 | assert.NoError(err) 218 | }, 219 | }, 220 | { 221 | "No fatal error if delete issue fails", 222 | cfg4, 223 | func(src, dst *fakeClient) { 224 | src.issues = makeIssues("issue1") 225 | src.errors.deleteIssue = errors.New("err") 226 | }, 227 | func(err error, src, dst *fakeClient) { 228 | // No error since we don't want the program to exit. 229 | assert.NoError(err) 230 | }, 231 | }, 232 | } 233 | 234 | for _, run := range runs { 235 | t.Run(run.name, func(t *testing.T) { 236 | conf, err := config.Parse(strings.NewReader(run.config)) 237 | require.NoError(err) 238 | // Load the conf. 239 | m, err = New(conf) 240 | require.NoError(err) 241 | // Setup. 242 | run.setup(source(m), dest(m)) 243 | // Run the migration. 244 | err = m.Migrate() 245 | // Asserts and tear down. 246 | run.asserts(err, source(m), dest(m)) 247 | }) 248 | } 249 | } 250 | 251 | func TestMigrateIssue(t *testing.T) { 252 | assert := assert.New(t) 253 | require := require.New(t) 254 | 255 | runs := []struct { 256 | name string // Sub-test name 257 | config string // YAML config 258 | setup func(src, dst *fakeClient) // Defines any option before calling Migrate() 259 | asserts func(err error, src, dst *fakeClient) 260 | }{ 261 | { 262 | "Duplicate issue fails", 263 | cfg2, 264 | func(src, dst *fakeClient) { 265 | src.issues = makeIssues("issue1") 266 | dst.issues = makeIssues("issue1") 267 | }, 268 | func(err error, src, dst *fakeClient) { 269 | assert.Error(err) 270 | }, 271 | }, 272 | { 273 | "Get issue fails", 274 | cfg2, 275 | func(src, dst *fakeClient) { 276 | src.issues = makeIssues("issue1") 277 | src.errors.getIssue = errors.New("err") 278 | }, 279 | func(err error, src, dst *fakeClient) { 280 | assert.Error(err) 281 | }, 282 | }, 283 | { 284 | "List project issues fails", 285 | cfg2, 286 | func(src, dst *fakeClient) { 287 | src.issues = makeIssues("issue1") 288 | dst.errors.listProjetIssues = errors.New("err") 289 | }, 290 | func(err error, src, dst *fakeClient) { 291 | assert.Error(err) 292 | }, 293 | }, 294 | { 295 | "Assigned user, but no target user match", 296 | cfg2, 297 | func(src, dst *fakeClient) { 298 | src.issues = makeIssues("issue1") 299 | src.issues[0].Assignee.Username = "mat" 300 | }, 301 | func(err error, src, dst *fakeClient) { 302 | assert.NoError(err) 303 | assert.Empty(dst.issues[0].Assignee.Username) 304 | }, 305 | }, 306 | { 307 | "Assigned user, fetching users fails", 308 | cfg2, 309 | func(src, dst *fakeClient) { 310 | src.issues = makeIssues("issue1") 311 | src.issues[0].Assignee.Username = "mat" 312 | dst.errors.listUsers = errors.New("err") 313 | }, 314 | func(err error, src, dst *fakeClient) { 315 | assert.Error(err) 316 | }, 317 | }, 318 | { 319 | "Issue has assigned user, target user match", 320 | cfg2, 321 | func(src, dst *fakeClient) { 322 | src.issues = makeIssues("issue1") 323 | src.issues[0].Assignee.Username = "mat" 324 | dst.users = makeUsers("mat") 325 | }, 326 | func(err error, src, dst *fakeClient) { 327 | assert.NoError(err) 328 | if assert.Len(dst.issues, 1) { 329 | assert.Equal("mat", dst.issues[0].Assignee.Username) 330 | } 331 | }, 332 | }, 333 | { 334 | "Issue has a milestone, not target match", 335 | cfg2, 336 | func(src, dst *fakeClient) { 337 | src.issues = makeIssues("issue1") 338 | m := &glab.Milestone{ 339 | Title: "v1.0", 340 | } 341 | src.issues[0].Milestone = m 342 | }, 343 | func(err error, src, dst *fakeClient) { 344 | assert.NoError(err) 345 | if assert.Len(dst.milestones, 1) { 346 | assert.Equal("v1.0", dst.milestones[0].Title) 347 | } 348 | }, 349 | }, 350 | { 351 | "Issue has a milestone, create target milestone error", 352 | cfg2, 353 | func(src, dst *fakeClient) { 354 | src.issues = makeIssues("issue1") 355 | m := &glab.Milestone{ 356 | Title: "v1.0", 357 | } 358 | src.issues[0].Milestone = m 359 | dst.errors.createMilestone = errors.New("err") 360 | }, 361 | func(err error, src, dst *fakeClient) { 362 | assert.Error(err) 363 | }, 364 | }, 365 | { 366 | "Issue has a milestone, list target milestones error", 367 | cfg2, 368 | func(src, dst *fakeClient) { 369 | src.issues = makeIssues("issue1") 370 | m := &glab.Milestone{ 371 | Title: "v1.0", 372 | } 373 | src.issues[0].Milestone = m 374 | dst.errors.listMilestones = errors.New("err") 375 | }, 376 | func(err error, src, dst *fakeClient) { 377 | assert.Error(err) 378 | }, 379 | }, 380 | { 381 | "Issue has a milestone, found on target", 382 | cfg2, 383 | func(src, dst *fakeClient) { 384 | src.issues = makeIssues("issue1") 385 | m := &glab.Milestone{ 386 | Title: "v1.0", 387 | } 388 | src.issues[0].Milestone = m 389 | dst.milestones = makeMilestones("v1.0") 390 | }, 391 | func(err error, src, dst *fakeClient) { 392 | assert.NoError(err) 393 | assert.Len(dst.milestones, 1) 394 | }, 395 | }, 396 | { 397 | "Copy existing labels", 398 | cfg2, 399 | func(src, dst *fakeClient) { 400 | src.issues = makeIssues("issue1") 401 | src.issues[0].Labels = []string{"P1", "P2"} 402 | }, 403 | func(err error, src, dst *fakeClient) { 404 | assert.NoError(err) 405 | }, 406 | }, 407 | { 408 | "Failing creating target issue", 409 | cfg2, 410 | func(src, dst *fakeClient) { 411 | src.issues = makeIssues("issue1") 412 | dst.errors.createIssue = errors.New("err") 413 | }, 414 | func(err error, src, dst *fakeClient) { 415 | assert.Error(err) 416 | }, 417 | }, 418 | { 419 | "Failing creating target issue, URI too long HTTP error, empty issue description", 420 | cfg2, 421 | func(src, dst *fakeClient) { 422 | src.issues = makeIssues("issue1") 423 | dst.errors.createIssue = errors.New("err") 424 | dst.httpErrorRaiseURITooLong = true 425 | }, 426 | func(err error, src, dst *fakeClient) { 427 | assert.Error(err) 428 | }, 429 | }, 430 | { 431 | "Failing creating target issue, URI too long HTTP error, with issue description", 432 | cfg2, 433 | func(src, dst *fakeClient) { 434 | src.issues = makeIssues("issue1") 435 | buf := make([]byte, 1128) 436 | desc := bytes.NewBuffer(buf) 437 | desc.WriteString("Some desc") 438 | src.issues[0].Description = desc.String() 439 | dst.errors.createIssue = errors.New("err") 440 | dst.httpErrorRaiseURITooLong = true 441 | }, 442 | func(err error, src, dst *fakeClient) { 443 | assert.Error(err) 444 | }, 445 | }, 446 | { 447 | "List issue notes fails", 448 | cfg2, 449 | func(src, dst *fakeClient) { 450 | src.issues = makeIssues("issue1") 451 | src.errors.listIssueNotes = errors.New("err") 452 | }, 453 | func(err error, src, dst *fakeClient) { 454 | assert.Error(err) 455 | }, 456 | }, 457 | { 458 | "Issue has notes", 459 | cfg2, 460 | func(src, dst *fakeClient) { 461 | src.issues = makeIssues("issue1") 462 | src.issueNotes = makeNotes("n1", "n2") 463 | }, 464 | func(err error, src, dst *fakeClient) { 465 | assert.NoError(err) 466 | }, 467 | }, 468 | { 469 | "Issue with notes, create issue note error", 470 | cfg2, 471 | func(src, dst *fakeClient) { 472 | src.issues = makeIssues("issue1") 473 | src.issueNotes = makeNotes("n1", "n2") 474 | dst.errors.createIssueNote = errors.New("err") 475 | }, 476 | func(err error, src, dst *fakeClient) { 477 | assert.Error(err) 478 | }, 479 | }, 480 | { 481 | "Issue with notes, but description too long", 482 | cfg2, 483 | func(src, dst *fakeClient) { 484 | src.issues = makeIssues("issue1") 485 | src.issueNotes = makeNotes("n1", "n2") 486 | // Large data buffer to raise an URITooLong error. 487 | buf := make([]byte, 1128) 488 | desc := bytes.NewBuffer(buf) 489 | desc.WriteString("Some desc") 490 | src.issueNotes[1].Body = desc.String() 491 | dst.httpErrorRaiseURITooLong = true 492 | dst.errors.createIssueNote = errors.New("err") 493 | }, 494 | func(err error, src, dst *fakeClient) { 495 | assert.Error(err) 496 | }, 497 | }, 498 | { 499 | "Closed issue", 500 | cfg2, 501 | func(src, dst *fakeClient) { 502 | src.issues = makeIssues("issue1") 503 | src.issues[0].State = "closed" 504 | }, 505 | func(err error, src, dst *fakeClient) { 506 | assert.NoError(err) 507 | }, 508 | }, 509 | { 510 | "Closed issue, with error when updating target issue", 511 | cfg2, 512 | func(src, dst *fakeClient) { 513 | src.issues = makeIssues("issue1") 514 | src.issues[0].State = "closed" 515 | dst.errors.updateIssue = errors.New("err") 516 | }, 517 | func(err error, src, dst *fakeClient) { 518 | assert.Error(err) 519 | }, 520 | }, 521 | } 522 | for _, run := range runs { 523 | t.Run(run.name, func(t *testing.T) { 524 | conf, err := config.Parse(strings.NewReader(run.config)) 525 | require.NoError(err) 526 | // Load the conf. 527 | m, err := New(conf) 528 | require.NoError(err) 529 | _, err = m.SourceProject(m.params.SrcPrj.Name) 530 | require.NoError(err) 531 | _, err = m.DestProject(m.params.DstPrj.Name) 532 | require.NoError(err) 533 | // Setup. 534 | run.setup(source(m), dest(m)) 535 | // Run the migration. 536 | err = m.migrateIssue(0) 537 | // Asserts and tear down. 538 | run.asserts(err, source(m), dest(m)) 539 | }) 540 | } 541 | } 542 | 543 | func makeLabels(names ...string) []*glab.Label { 544 | labels := make([]*glab.Label, len(names)) 545 | for k, n := range names { 546 | labels[k] = &glab.Label{ 547 | ID: k, 548 | Name: n, 549 | } 550 | } 551 | return labels 552 | } 553 | 554 | func makeMilestones(names ...string) []*glab.Milestone { 555 | ms := make([]*glab.Milestone, len(names)) 556 | for k, n := range names { 557 | ms[k] = &glab.Milestone{ 558 | ID: k, 559 | Title: n, 560 | } 561 | } 562 | return ms 563 | } 564 | 565 | func makeIssues(names ...string) []*glab.Issue { 566 | issues := make([]*glab.Issue, len(names)) 567 | for k, n := range names { 568 | issues[k] = &glab.Issue{ 569 | ID: k, 570 | Title: n, 571 | } 572 | issues[k].Assignee = &glab.IssueAssignee{ 573 | Name: "me", 574 | } 575 | } 576 | return issues 577 | } 578 | 579 | func makeUsers(names ...string) []*glab.User { 580 | users := make([]*glab.User, len(names)) 581 | for k, n := range names { 582 | users[k] = &glab.User{ 583 | ID: k, 584 | Username: n, 585 | } 586 | } 587 | return users 588 | } 589 | 590 | func makeNotes(names ...string) []*glab.Note { 591 | notes := make([]*glab.Note, len(names)) 592 | now := time.Now() 593 | for k, n := range names { 594 | notes[k] = &glab.Note{ 595 | ID: k, 596 | Title: n, 597 | CreatedAt: &now, 598 | } 599 | notes[k].Author.Name = "me" 600 | notes[k].Author.Username = "me" 601 | } 602 | return notes 603 | } 604 | -------------------------------------------------------------------------------- /qr-donate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotsunami/gitlab-copy/9186bfba94a9766bf7a52a19ff677fedfe65f1d3/qr-donate.png -------------------------------------------------------------------------------- /stats/project.go: -------------------------------------------------------------------------------- 1 | package stats 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/gotsunami/gitlab-copy/gitlab" 8 | "github.com/gotsunami/gitlab-copy/migration" 9 | glab "github.com/xanzy/go-gitlab" 10 | ) 11 | 12 | type ProjectStats struct { 13 | Project *glab.Project 14 | NbIssues, NbClosed, NbOpened, NbNotes int 15 | Milestones, Labels map[string]int 16 | } 17 | 18 | func NewProject(prj *glab.Project) *ProjectStats { 19 | p := new(ProjectStats) 20 | p.Project = prj 21 | p.Milestones = make(map[string]int) 22 | p.Labels = make(map[string]int) 23 | return p 24 | } 25 | 26 | func (p *ProjectStats) String() string { 27 | return fmt.Sprintf("%d issues (%d opened, %d closed)", p.NbIssues, p.NbOpened, p.NbClosed) 28 | } 29 | 30 | func (p *ProjectStats) pagination(client gitlab.GitLaber, f func(gitlab.GitLaber, *glab.ListOptions) (bool, error)) error { 31 | if client == nil { 32 | return errors.New("nil client") 33 | } 34 | 35 | curPage := 1 36 | opts := &glab.ListOptions{PerPage: migration.ResultsPerPage, Page: curPage} 37 | 38 | for { 39 | stop, err := f(client, opts) 40 | if err != nil { 41 | return err 42 | } 43 | if stop { 44 | break 45 | } 46 | curPage++ 47 | opts.Page = curPage 48 | } 49 | return nil 50 | } 51 | 52 | func (p *ProjectStats) ComputeStats(client gitlab.GitLaber) error { 53 | if client == nil { 54 | return errors.New("nil client") 55 | } 56 | 57 | action := func(c gitlab.GitLaber, lo *glab.ListOptions) (bool, error) { 58 | opts := &glab.ListProjectIssuesOptions{ListOptions: glab.ListOptions{PerPage: lo.PerPage, Page: lo.Page}} 59 | issues, _, err := client.ListProjectIssues(p.Project.ID, opts) 60 | if err != nil { 61 | return false, err 62 | } 63 | if len(issues) > 0 { 64 | p.NbIssues += len(issues) 65 | for _, issue := range issues { 66 | switch issue.State { 67 | case "opened": 68 | p.NbOpened++ 69 | case "closed": 70 | p.NbClosed++ 71 | } 72 | if issue.Milestone != nil && issue.Milestone.Title != "" { 73 | p.Milestones[issue.Milestone.Title]++ 74 | } 75 | } 76 | } else { 77 | // Exit 78 | return true, nil 79 | } 80 | return false, nil 81 | } 82 | 83 | if err := p.pagination(client, action); err != nil { 84 | return err 85 | } 86 | 87 | labels, _, err := client.ListLabels(p.Project.ID, nil) 88 | if err != nil { 89 | return fmt.Errorf("source: can't fetch labels: %s", err.Error()) 90 | } 91 | for _, label := range labels { 92 | p.Labels[label.Name]++ 93 | } 94 | return nil 95 | } 96 | 97 | func (p *ProjectStats) ComputeIssueNotes(client gitlab.GitLaber) error { 98 | if client == nil { 99 | return errors.New("nil client") 100 | } 101 | 102 | action := func(c gitlab.GitLaber, lo *glab.ListOptions) (bool, error) { 103 | opts := &glab.ListProjectIssuesOptions{ListOptions: glab.ListOptions{PerPage: lo.PerPage, Page: lo.Page}} 104 | issues, _, err := client.ListProjectIssues(p.Project.ID, opts) 105 | if err != nil { 106 | return false, err 107 | } 108 | if len(issues) > 0 { 109 | for _, issue := range issues { 110 | notes, _, err := client.ListIssueNotes(p.Project.ID, issue.IID, nil) 111 | if err != nil { 112 | return false, err 113 | } 114 | p.NbNotes += len(notes) 115 | } 116 | } else { 117 | // Exit 118 | return true, nil 119 | } 120 | return false, nil 121 | } 122 | 123 | if err := p.pagination(client, action); err != nil { 124 | return err 125 | } 126 | return nil 127 | } 128 | -------------------------------------------------------------------------------- /tools/coverage.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Generate test coverage statistics for Go packages. 3 | # 4 | # Works around the fact that `go test -coverprofile` currently does not work 5 | # with multiple packages, see https://code.google.com/p/go/issues/detail?id=6909 6 | # 7 | # Usage: script/coverage [--html|--coveralls] 8 | # 9 | # --html Additionally create HTML report and open it in browser 10 | # --coveralls Push coverage statistics to coveralls.io 11 | # 12 | 13 | set -e 14 | 15 | PROJECT= 16 | workdir=.cover 17 | profile="$workdir/cover.out" 18 | mode=count 19 | 20 | generate_cover_data() { 21 | rm -rf "$workdir" 22 | mkdir "$workdir" 23 | 24 | for pkg in "$@"; do 25 | f="$workdir/$(echo $pkg | tr / -).cover" 26 | env GOPATH=$PROJECT:$PROJECT/vendor go test -covermode="$mode" -coverprofile="$f" "$pkg" 27 | done 28 | 29 | echo "mode: $mode" >"$profile" 30 | grep -h -v "^mode:" "$workdir"/*.cover >>"$profile" 31 | } 32 | 33 | show_cover_report() { 34 | env GOPATH=$PROJECT:$PROJECT/vendor go tool cover -${1}="$profile" 35 | } 36 | 37 | push_to_coveralls() { 38 | echo "Pushing coverage statistics to coveralls.io" 39 | goveralls -coverprofile="$profile" 40 | } 41 | 42 | make_coverage() { 43 | generate_cover_data $(go list ./...) 44 | show_cover_report func 45 | } 46 | 47 | if [ "$#" -eq 1 ]; then 48 | PROJECT="$1" 49 | if [ ! -d $PROJECT ]; then 50 | echo "Project dir not found: $PROJECT" 51 | exit 2 52 | fi 53 | make_coverage 54 | 55 | elif [ "$#" -eq 2 ]; then 56 | case "$1" in "") 57 | ;; 58 | --html) 59 | ;; 60 | --coveralls) 61 | ;; 62 | *) 63 | echo >&2 "error: invalid option: $1 (must be --html or --coveralls)"; exit 2 ;; 64 | esac 65 | 66 | PROJECT="$2" 67 | if [ ! -d $PROJECT ]; then 68 | echo "Project dir not found: $PROJECT" 69 | exit 2 70 | fi 71 | 72 | make_coverage 73 | 74 | case "$1" in "") 75 | ;; 76 | --html) 77 | show_cover_report html ;; 78 | --coveralls) 79 | push_to_coveralls ;; 80 | esac 81 | 82 | else 83 | echo "Wrong number of arguments" 84 | exit 2 85 | fi 86 | -------------------------------------------------------------------------------- /version.mk: -------------------------------------------------------------------------------- 1 | VERSION = $(shell git describe --tags) 2 | GITREV = $(shell git rev-parse --verify --short HEAD) 3 | GITBRANCH = $(shell git rev-parse --abbrev-ref HEAD) 4 | DATE = $(shell LANG=US date +"%a, %d %b %Y %X %z") 5 | 6 | GO_LDFLAGS += -X 'github.com/gotsunami/gitlab-copy/config.Version=$(VERSION)' 7 | GO_LDFLAGS += -X 'github.com/gotsunami/gitlab-copy/config.GitRev=$(GITREV)' 8 | GO_LDFLAGS += -X 'github.com/gotsunami/gitlab-copy/config.GitBranch=$(GITBRANCH)' 9 | GO_LDFLAGS += -X 'github.com/gotsunami/gitlab-copy/config.BuildDate=$(DATE)' 10 | --------------------------------------------------------------------------------