Browse Source

Web interface implementation (#18)

pull/26/head
Jonas Franz 9 months ago
parent
commit
2872647d3d
51 changed files with 2344 additions and 176 deletions
  1. 22
    4
      .drone.yml
  2. 2
    0
      .gitignore
  3. 4
    1
      Dockerfile
  4. 25
    0
      Dockerfile.web
  5. 104
    7
      Gopkg.lock
  6. 27
    0
      Gopkg.toml
  7. 1
    1
      LICENSE
  8. 40
    5
      Makefile
  9. 69
    5
      README.md
  10. 28
    33
      cmd/migrate-all.go
  11. 21
    75
      cmd/migrate.go
  12. 0
    23
      cmd/migrate_test.go
  13. 48
    0
      cmd/web.go
  14. 13
    0
      commands.go
  15. 14
    0
      commands_web.go
  16. 8
    0
      config.example.yml
  17. 13
    0
      config/config.go
  18. 2
    6
      main.go
  19. 264
    0
      migrations/github.go
  20. 55
    0
      migrations/github_test.go
  21. 9
    5
      migrations/issue.go
  22. 2
    2
      migrations/issue_test.go
  23. 114
    0
      migrations/job.go
  24. 66
    0
      migrations/job_test.go
  25. 33
    5
      migrations/migratory.go
  26. 32
    0
      migrations/options.go
  27. 18
    4
      migrations/utils.go
  28. 50
    0
      web/auth/gitea.go
  29. 68
    0
      web/auth/github.go
  30. 115
    0
      web/context/context.go
  31. 58
    0
      web/fs.go
  32. 62
    0
      web/migration/repos.go
  33. 14
    0
      web/migration/status.go
  34. 364
    0
      web/public/css/semantic.min.css
  35. 4
    0
      web/public/js/jquery-3.1.1.min.js
  36. 121
    0
      web/public/js/repos-status.js
  37. 30
    0
      web/public/js/select-repos.js
  38. 11
    0
      web/public/js/semantic.min.js
  39. 68
    0
      web/router.go
  40. 2
    0
      web/templates/base/footer.tmpl
  41. 34
    0
      web/templates/base/head.tmpl
  42. 32
    0
      web/templates/dashboard.tmpl
  43. 67
    0
      web/templates/login_gitea.tmpl
  44. 22
    0
      web/templates/login_github.tmpl
  45. 54
    0
      web/templates/migration.tmpl
  46. 1
    0
      web/templates/modules/username.tmpl
  47. 77
    0
      web/templates/repos.tmpl
  48. 14
    0
      web/templates/status/403.tmpl
  49. 14
    0
      web/templates/status/404.tmpl
  50. 14
    0
      web/templates/status/500.tmpl
  51. 14
    0
      web/templates/status/unknown_error.tmpl

+ 22
- 4
.drone.yml View File

@@ -17,13 +17,15 @@ pipeline:
17 17
     commands:
18 18
       - go get -u github.com/golang/dep/cmd/dep
19 19
       - dep ensure
20
+      - go get -u github.com/gobuffalo/packr/...
21
+      - packr -z
20 22
   test:
21 23
     image: golang:1.10
22 24
     pull: true
23 25
     environment:
24 26
       GOPATH: /go
25 27
     commands:
26
-      - make test
28
+      - make test build
27 29
   static:
28 30
     image: golang:1.10
29 31
     pull: true
@@ -40,9 +42,16 @@ pipeline:
40 42
     environment:
41 43
       GOPATH: /go
42 44
     commands:
43
-      - make generate-release-file release
45
+    - make generate-release-file release
44 46
     when:
45 47
       event: [ tag ]
48
+  clean:
49
+    image: golang:1.10
50
+    pull: true
51
+    environment:
52
+      GOPATH: /go
53
+    commands:
54
+      - packr clean
46 55
   gitea:
47 56
     image: plugins/gitea-release:latest
48 57
     pull: true
@@ -61,11 +70,20 @@ pipeline:
61 70
     image: plugins/docker:17.12
62 71
     secrets: [ docker_username, docker_password ]
63 72
     pull: true
64
-    repo: jonasfranz/gitea-github-migrator
73
+    repo: ggmigrator/cli
74
+    default_tags: true
75
+    when:
76
+      event: [ push, tag ]
77
+
78
+  docker-web:
79
+    image: plugins/docker:17.12
80
+    secrets: [ docker_username, docker_password ]
81
+    pull: true
82
+    dockerfile: Dockerfile.web
83
+    repo: ggmigrator/web
65 84
     default_tags: true
66 85
     when:
67 86
       event: [ push, tag ]
68
-      branch: [ master ]
69 87
   s3:
70 88
     image: plugins/s3:1
71 89
     pull: true

+ 2
- 0
.gitignore View File

@@ -10,7 +10,9 @@
10 10
 
11 11
 # Output of the go coverage tool, specifically when used with LiteIDE
12 12
 *.out
13
+*-packr.go
13 14
 
14 15
 # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736
15 16
 .glide/
16 17
 
18
+data/

+ 4
- 1
Dockerfile View File

@@ -1,8 +1,11 @@
1 1
 #Build stage
2 2
 FROM golang:1.10-alpine3.7 AS build-env
3 3
 
4
+
5
+ARG VERSION
6
+
4 7
 #Build deps
5
-RUN apk --no-cache add build-base git
8
+RUN apk --no-cache add build-base git ca-certificates
6 9
 
7 10
 #Setup repo
8 11
 COPY . ${GOPATH}/src/git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator

+ 25
- 0
Dockerfile.web View File

@@ -0,0 +1,25 @@
1
+#Build stage
2
+FROM golang:1.10-alpine3.7 AS build-env
3
+
4
+ARG VERSION
5
+
6
+#Build deps
7
+RUN apk --no-cache add build-base git ca-certificates
8
+
9
+#Setup repo
10
+COPY . ${GOPATH}/src/git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator
11
+WORKDIR ${GOPATH}/src/git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator
12
+
13
+RUN make clean docker-binary-web
14
+
15
+FROM alpine:3.7
16
+LABEL maintainer="info@jonasfranz.software"
17
+
18
+COPY --from=build-env /go/src/git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/gitea-github-migrator /usr/local/bin/gitea-github-migrator
19
+
20
+VOLUME "/data"
21
+VOLUME "/usr/local/bin/data"
22
+
23
+COPY --from=build-env /go/src/git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/config.example.yml /data/config.example.yml
24
+
25
+ENTRYPOINT ["/usr/local/bin/gitea-github-migrator", "web", "-c", "/data/config.yml"]

+ 104
- 7
Gopkg.lock View File

@@ -7,17 +7,59 @@
7 7
   packages = ["gitea"]
8 8
   revision = "79a281c4e34ae44cf96a23f0283729a074a6c2a0"
9 9
 
10
+[[projects]]
11
+  name = "github.com/BurntSushi/toml"
12
+  packages = ["."]
13
+  revision = "b26d9c308763d68093482582cea63d69be07a0f0"
14
+  version = "v0.3.0"
15
+
16
+[[projects]]
17
+  name = "github.com/Unknwon/com"
18
+  packages = ["."]
19
+  revision = "7677a1d7c1137cd3dd5ba7a076d0c898a1ef4520"
20
+  version = "master"
21
+
22
+[[projects]]
23
+  branch = "master"
24
+  name = "github.com/adam-hanna/randomstrings"
25
+  packages = ["."]
26
+  revision = "88fd7c52a2c704c5d530718c1be454292a806e2b"
27
+
10 28
 [[projects]]
11 29
   name = "github.com/davecgh/go-spew"
12 30
   packages = ["spew"]
13 31
   revision = "346938d642f2ec3594ed81d874461961cd0faa76"
14 32
   version = "v1.1.0"
15 33
 
34
+[[projects]]
35
+  branch = "master"
36
+  name = "github.com/go-macaron/binding"
37
+  packages = ["."]
38
+  revision = "ac54ee249c27dca7e76fad851a4a04b73bd1b183"
39
+
40
+[[projects]]
41
+  branch = "master"
42
+  name = "github.com/go-macaron/inject"
43
+  packages = ["."]
44
+  revision = "d8a0b8677191f4380287cfebd08e462217bac7ad"
45
+
46
+[[projects]]
47
+  branch = "master"
48
+  name = "github.com/go-macaron/session"
49
+  packages = ["."]
50
+  revision = "b8e286a0dba8f4999042d6b258daf51b31d08938"
51
+
52
+[[projects]]
53
+  name = "github.com/gobuffalo/packr"
54
+  packages = ["."]
55
+  revision = "bd47f2894846e32edcf9aa37290fef76c327883f"
56
+  version = "v1.11.1"
57
+
16 58
 [[projects]]
17 59
   name = "github.com/golang/protobuf"
18 60
   packages = ["proto"]
19
-  revision = "925541529c1fa6821df4e44ce2723319eb2be768"
20
-  version = "v1.0.0"
61
+  revision = "b4deda0973fb4c70b50d226b1af49f3da59f5265"
62
+  version = "v1.1.0"
21 63
 
22 64
 [[projects]]
23 65
   name = "github.com/google/go-github"
@@ -31,12 +73,30 @@
31 73
   packages = ["query"]
32 74
   revision = "53e6ce116135b80d037921a7fdd5138cf32d7a8a"
33 75
 
76
+[[projects]]
77
+  branch = "master"
78
+  name = "github.com/jinzhu/configor"
79
+  packages = ["."]
80
+  revision = "4edaf76fe18865e36de734d887f4c01bd05707af"
81
+
82
+[[projects]]
83
+  name = "github.com/pkg/errors"
84
+  packages = ["."]
85
+  revision = "645ef00459ed84a119197bfb8d8205042c6df63d"
86
+  version = "v0.8.0"
87
+
34 88
 [[projects]]
35 89
   name = "github.com/pmezard/go-difflib"
36 90
   packages = ["difflib"]
37 91
   revision = "792786c7400a136282c1664665ae0a8db921c6c2"
38 92
   version = "v1.0.0"
39 93
 
94
+[[projects]]
95
+  name = "github.com/sirupsen/logrus"
96
+  packages = ["."]
97
+  revision = "3e01752db0189b9157070a0e1668a620f9a85da2"
98
+  version = "v1.0.6"
99
+
40 100
 [[projects]]
41 101
   name = "github.com/stretchr/testify"
42 102
   packages = ["assert"]
@@ -49,6 +109,15 @@
49 109
   revision = "cfb38830724cc34fedffe9a2a29fb54fa9169cd1"
50 110
   version = "v1.20.0"
51 111
 
112
+[[projects]]
113
+  branch = "master"
114
+  name = "golang.org/x/crypto"
115
+  packages = [
116
+    "pbkdf2",
117
+    "ssh/terminal"
118
+  ]
119
+  revision = "a8fb68e7206f8c78be19b432c58eb52a6aa34462"
120
+
52 121
 [[projects]]
53 122
   branch = "master"
54 123
   name = "golang.org/x/net"
@@ -56,16 +125,26 @@
56 125
     "context",
57 126
     "context/ctxhttp"
58 127
   ]
59
-  revision = "24dd3780ca4f75fed9f321890729414a4b5d3f13"
128
+  revision = "db08ff08e8622530d9ed3a0e8ac279f6d4c02196"
60 129
 
61 130
 [[projects]]
62 131
   branch = "master"
63 132
   name = "golang.org/x/oauth2"
64 133
   packages = [
65 134
     ".",
135
+    "github",
66 136
     "internal"
67 137
   ]
68
-  revision = "fdc9e635145ae97e6c2cb777c48305600cf515cb"
138
+  revision = "1e0a3fa8ba9a5c9eb35c271780101fdaf1b205d7"
139
+
140
+[[projects]]
141
+  branch = "master"
142
+  name = "golang.org/x/sys"
143
+  packages = [
144
+    "unix",
145
+    "windows"
146
+  ]
147
+  revision = "ac767d655b305d4e9612f5f6e33120b9176c4ad4"
69 148
 
70 149
 [[projects]]
71 150
   name = "google.golang.org/appengine"
@@ -78,12 +157,30 @@
78 157
     "internal/urlfetch",
79 158
     "urlfetch"
80 159
   ]
81
-  revision = "150dc57a1b433e64154302bdc40b6bb8aefa313a"
82
-  version = "v1.0.0"
160
+  revision = "b1f26356af11148e710935ed1ac8a7f5702c7612"
161
+  version = "v1.1.0"
162
+
163
+[[projects]]
164
+  name = "gopkg.in/ini.v1"
165
+  packages = ["."]
166
+  revision = "06f5f3d67269ccec1fe5fe4134ba6e982984f7f5"
167
+  version = "v1.37.0"
168
+
169
+[[projects]]
170
+  name = "gopkg.in/macaron.v1"
171
+  packages = ["."]
172
+  revision = "c1be95e6d21e769e44e1ec33cec9da5837861c10"
173
+  version = "v1.3.1"
174
+
175
+[[projects]]
176
+  name = "gopkg.in/yaml.v2"
177
+  packages = ["."]
178
+  revision = "5420a8b6744d3b0345ab293f6fcba19c978f1183"
179
+  version = "v2.2.1"
83 180
 
84 181
 [solve-meta]
85 182
   analyzer-name = "dep"
86 183
   analyzer-version = 1
87
-  inputs-digest = "a63a8945bd36ecc14dfbb7f9894543855f9e9e2e493dfdb77c06b4a12aada8a4"
184
+  inputs-digest = "0c4f08e8c96a1b866394a858e61289c637d938c3476b14446e483acd212ba06f"
88 185
   solver-name = "gps-cdcl"
89 186
   solver-version = 1

+ 27
- 0
Gopkg.toml View File

@@ -16,3 +16,30 @@
16 16
 [[constraint]]
17 17
   name = "github.com/stretchr/testify"
18 18
   version = "1.2.2"
19
+
20
+[[constraint]]
21
+  branch = "master"
22
+  name = "github.com/jinzhu/configor"
23
+
24
+[[constraint]]
25
+  name = "github.com/gobuffalo/packr"
26
+  version = "1.11.1"
27
+
28
+[[constraint]]
29
+  branch = "master"
30
+  name = "github.com/adam-hanna/randomstrings"
31
+
32
+[[constraint]]
33
+  name = "gopkg.in/macaron.v1"
34
+  version = "1.3.1"
35
+
36
+[[constraint]]
37
+  branch = "master"
38
+  name = "github.com/go-macaron/session"
39
+
40
+[[constraint]]
41
+  branch = "master"
42
+  name = "github.com/go-macaron/binding"
43
+[[constraint]]
44
+  name = "github.com/sirupsen/logrus"
45
+  version = "1.0.6"

+ 1
- 1
LICENSE View File

@@ -1,5 +1,5 @@
1 1
 MIT License
2
-Copyright (c) <year> <copyright holders>
2
+Copyright (c) 2018 Jonas Franz
3 3
 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 4
 The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
5 5
 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

+ 40
- 5
Makefile View File

@@ -17,20 +17,55 @@ LDFLAGS := -X main.version=$(VERSION) -X main.build=$(DRONE_BUILD_NUMBER)
17 17
 .PHONY: all
18 18
 all:
19 19
 
20
-.PHONY: docker-binary
21
-docker-binary:
20
+.PHONY: build
21
+build:
22 22
 	go build -ldflags "$(LDFLAGS)" -o gitea-github-migrator
23 23
 
24
+.PHONY: build-binary-web
25
+build-binary-web:
26
+	go build -ldflags "$(LDFLAGS)" -tags web -o gitea-github-migrator
27
+
28
+.PHONY: build-web
29
+build-web: packr build-binary-web packr-clean
30
+
31
+.PHONY: packr
32
+packr:
33
+	@hash packr > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
34
+		$(GO) get -u github.com/gobuffalo/packr/...; \
35
+	fi
36
+	packr -z
37
+
38
+.PHONY: packr-clean
39
+packr-clean:
40
+	@hash packr > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
41
+		$(GO) get -u github.com/gobuffalo/packr/...; \
42
+	fi
43
+	packr clean
44
+
45
+.PHONY: clean
46
+clean: packr-clean
47
+	go clean ./...
48
+
49
+.PHONY: docker-binary
50
+docker-binary: build
51
+
52
+.PHONY: docker-binary-web
53
+docker-binary-web: build-web
54
+
55
+
24 56
 .PHONY: generate-release-file
25 57
 generate-release-file:
26 58
 	echo $(VERSION) > .version
27 59
 
28 60
 .PHONY: release
29
-release:
61
+release: packr release-builds packr-clean
62
+
63
+.PHONY: release-builds
64
+release-builds:
30 65
 	@hash gox > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
31 66
 		$(GO) get -u github.com/mitchellh/gox; \
32 67
 	fi
33
-	gox -ldflags "$(LDFLAGS)" -output "releases/gitea-github-migrator_{{.OS}}_{{.Arch}}"
68
+	gox -ldflags "$(LDFLAGS)" -tags web -output "releases/gitea-github-migrator_{{.OS}}_{{.Arch}}"
34 69
 
35 70
 .PHONY: lint
36 71
 lint:
@@ -45,5 +80,5 @@ vet:
45 80
 
46 81
 .PHONY: test
47 82
 test: lint vet
48
-	go test -cover ./...
83
+	go test -tags web -cover ./...
49 84
 	

+ 69
- 5
README.md View File

@@ -1,12 +1,15 @@
1 1
 # gitea-github-migrator
2
+
2 3
 [![Build Status](https://drone.jonasfranz.software/api/badges/JonasFranzDEV/gitea-github-migrator/status.svg)](https://drone.jonasfranz.software/JonasFranzDEV/gitea-github-migrator)
3 4
 [![Latest Release](https://img.shields.io/badge/dynamic/json.svg?label=release&url=https%3A%2F%2Fgit.jonasfranz.software%2Fapi%2Fv1%2Frepos%2FJonasFranzDEV%2Fgitea-github-migrator%2Freleases&query=%24%5B0%5D.tag_name)](https://git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/releases)
4
-[![Docker Pulls](https://img.shields.io/docker/pulls/jonasfranz/gitea-github-migrator.svg)](https://hub.docker.com/r/jonasfranz/gitea-github-migrator/)
5
+[![Docker Pulls](https://img.shields.io/docker/pulls/ggmigrator/cli.svg)](https://hub.docker.com/r/ggmigrator/cli/)
6
+[![Docker Pulls](https://img.shields.io/docker/pulls/ggmigrator/web.svg)](https://hub.docker.com/r/ggmigrator/web/)
5 7
 
6 8
 A tool to migrate [GitHub](https://github.com) Repositories to [Gitea](https://gitea.io) including all issues, labels, milestones
7 9
 and comments.
8 10
 
9 11
 ## Features
12
+
10 13
 Migrates:
11 14
 
12 15
 - [x] Repositories
@@ -26,8 +29,11 @@ Migrates:
26 29
 go get git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator
27 30
 cd $GOPATH/src/git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator
28 31
 dep ensure
29
-go install
32
+make build
30 33
 ```
34
+#### Web Support
35
+
36
+Run `make web-build` instead of `make build` to include web support.
31 37
 
32 38
 ### From Binary
33 39
 We provide binaries of master builds and all releases at our [minio storage server](https://storage.h.jonasfranz.software/minio/gitea-github-migrator/dist/).
@@ -35,9 +41,30 @@ We provide binaries of master builds and all releases at our [minio storage serv
35 41
 Additionally we provide them for every release as release attachment under [releases](https://git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/releases).
36 42
 
37 43
 You don't need any dependencies except the binary to run the migrator.
44
+
45
+These binaries include web support by default.
46
+
47
+### From Docker image
48
+
49
+We provide a [cli docker image](https://hub.docker.com/r/ggmigrator/cli/):
50
+
51
+For master builds:
52
+```docker
53
+docker run ggmigrator/cli:latest
54
+```
55
+
56
+For release builds:
57
+```docker
58
+docker run ggmigrator/cli:0.0.10
59
+```
60
+
61
+
38 62
 ## Usage
39 63
 
64
+### Command line
65
+
40 66
 Migrate one repository:
67
+
41 68
 ```bash
42 69
 gitea-github-migrator migrate \
43 70
     --gh-repo owner/reponame \
@@ -46,9 +73,11 @@ gitea-github-migrator migrate \
46 73
     --token GITEA_TOKEN \
47 74
     --owner 1
48 75
 ```
76
+
49 77
 `gh-token` is only required if you have more than 50 issues / repositories.
50 78
 
51 79
 Migrate all repositories:
80
+
52 81
 ```bash
53 82
 gitea-github-migrator migrate-all \
54 83
     --gh-user username \
@@ -59,6 +88,7 @@ gitea-github-migrator migrate-all \
59 88
 ```
60 89
 
61 90
 Migrate all repositories without issues etc. (classic):
91
+
62 92
 ```bash
63 93
 gitea-github-migrator migrate-all \
64 94
     --gh-user username \
@@ -69,7 +99,41 @@ gitea-github-migrator migrate-all \
69 99
     --only-repo
70 100
 ```
71 101
 
102
+### Web interface
103
+
104
+Since 0.1.0 gitea-github-migrator comes with an integrated web interface.
105
+Follow these steps to get the web interface running:
106
+
107
+1. Download or build a web-capable binary of the gitea-github-migrator. The builds on our storage server are build with web support included.
108
+If you build from source, please follow [web support](#web-support).
109
+2. Create `config.yml` file and change the properties according to your wishes. Please keep in mind that
110
+you have to create a GitHub OAuth application to make the web interface work.
111
+3. Run `./gitea-github-migrator web`
112
+4. Visit `http://localhost:4000`
113
+
114
+#### Docker
115
+
116
+We're providing a docker image with web support. To start the web service please run:
117
+```docker
118
+docker run ggmigrator/web -p 4000:4000 -v data/:/data
119
+```
120
+Place your `config.yml` into `data/config.yml`.
121
+
122
+#### Config
123
+Example:
124
+```yaml
125
+# GitHub contains the OAuth2 application data obtainable from GitHub
126
+GitHub:
127
+  client_id: GITHUB_OAUTH_CLIENT_ID
128
+  client_secret: GITHUB_OAUTH_SECRET
129
+# Web contains the configuration for the integrated web server
130
+Web:
131
+  port: 4000
132
+  host: 0.0.0.0
133
+```
134
+
72 135
 ## Problems
73
-* This migration tool does not work with Gitea instances using a SQLite database.
74
-* Comments / Issues will be added in the name of the user to whom belongs the token (information about the original date and author will be added)
75
-* The current date is used for creation date (information about the actual date is added in a comment)
136
+
137
+- This migration tool does not work with Gitea instances using a SQLite database.
138
+- Comments / Issues will be added in the name of the user to whom belongs the token (information about the original date and author will be added)
139
+- The current date is used for creation date (information about the actual date is added in a comment)

+ 28
- 33
cmd/migrate-all.go View File

@@ -3,11 +3,11 @@ package cmd
3 3
 import (
4 4
 	"context"
5 5
 	"fmt"
6
-	"sync"
7 6
 
8 7
 	"code.gitea.io/sdk/gitea"
9 8
 	"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/migrations"
10 9
 	"github.com/google/go-github/github"
10
+	"github.com/sirupsen/logrus"
11 11
 	"github.com/urfave/cli"
12 12
 	"golang.org/x/oauth2"
13 13
 )
@@ -27,26 +27,14 @@ var CmdMigrateAll = cli.Command{
27 27
 }
28 28
 
29 29
 func runMigrateAll(ctx *cli.Context) error {
30
-	m := &migrations.Migratory{
31
-		Client:     gitea.NewClient(ctx.String("url"), ctx.String("token")),
32
-		Private:    ctx.Bool("private"),
33
-		NewOwnerID: ctx.Int64("owner"),
34
-	}
35
-	if m.NewOwnerID == 0 {
36
-		usr, err := m.Client.GetMyUserInfo()
37
-		if err != nil {
38
-			return fmt.Errorf("cannot fetch user info about current user: %v", err)
39
-		}
40
-		m.NewOwnerID = usr.ID
41
-	}
42
-	c := context.Background()
43
-
30
+	logrus.SetLevel(logrus.InfoLevel)
31
+	onlyRepos := ctx.Bool("only-repo")
44 32
 	var gc *github.Client
45 33
 	if ctx.IsSet("gh-token") {
46 34
 		ts := oauth2.StaticTokenSource(
47 35
 			&oauth2.Token{AccessToken: ctx.String("gh-token")},
48 36
 		)
49
-		tc := oauth2.NewClient(c, ts)
37
+		tc := oauth2.NewClient(context.Background(), ts)
50 38
 		gc = github.NewClient(tc)
51 39
 	} else {
52 40
 		gc = github.NewClient(nil)
@@ -58,7 +46,7 @@ func runMigrateAll(ctx *cli.Context) error {
58 46
 	// get all pages of results
59 47
 	var allRepos []*github.Repository
60 48
 	for {
61
-		repos, resp, err := gc.Repositories.List(c, ctx.String("gh-user"), opt)
49
+		repos, resp, err := gc.Repositories.List(context.Background(), ctx.String("gh-user"), opt)
62 50
 		if err != nil {
63 51
 			return err
64 52
 		}
@@ -68,25 +56,32 @@ func runMigrateAll(ctx *cli.Context) error {
68 56
 		}
69 57
 		opt.Page = resp.NextPage
70 58
 	}
71
-	errs := make(chan error, 1)
59
+	job := migrations.NewJob(&migrations.Options{
60
+		Private:    ctx.Bool("private"),
61
+		NewOwnerID: ctx.Int("owner"),
72 62
 
73
-	var wg sync.WaitGroup
74
-	wg.Add(len(allRepos))
63
+		Comments:     !onlyRepos,
64
+		Issues:       !onlyRepos,
65
+		Labels:       !onlyRepos,
66
+		Milestones:   !onlyRepos,
67
+		PullRequests: !onlyRepos,
68
+		Strategy:     migrations.Classic,
69
+	}, gitea.NewClient(ctx.String("url"), ctx.String("token")), gc)
70
+	if job.Options.NewOwnerID == 0 {
71
+		usr, err := job.Client.GetMyUserInfo()
72
+		if err != nil {
73
+			return fmt.Errorf("cannot fetch user info about current user: %v", err)
74
+		}
75
+		job.Options.NewOwnerID = int(usr.ID)
76
+	}
75 77
 	for _, repo := range allRepos {
76
-		go func(r *github.Repository) {
77
-			defer wg.Done()
78
-			errs <- migrate(c, gc, m, r.Owner.GetLogin(), r.GetName(), ctx.Bool("only-repo"))
79
-		}(repo)
78
+		job.Repositories = append(job.Repositories, repo.GetFullName())
80 79
 	}
81
-
82
-	go func() {
83
-		for i := range errs {
84
-			if i != nil {
85
-				fmt.Printf("error: %v", i)
86
-			}
80
+	errs := job.StartMigration()
81
+	for i := range errs {
82
+		if i != nil {
83
+			fmt.Printf("error: %v\n", i)
87 84
 		}
88
-	}()
89
-
90
-	wg.Wait()
85
+	}
91 86
 	return nil
92 87
 }

+ 21
- 75
cmd/migrate.go View File

@@ -3,11 +3,11 @@ package cmd
3 3
 import (
4 4
 	"context"
5 5
 	"fmt"
6
-	"strings"
7 6
 
8 7
 	"code.gitea.io/sdk/gitea"
9 8
 	"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/migrations"
10 9
 	"github.com/google/go-github/github"
10
+	"github.com/sirupsen/logrus"
11 11
 	"github.com/urfave/cli"
12 12
 	"golang.org/x/oauth2"
13 13
 )
@@ -28,95 +28,41 @@ var CmdMigrate = cli.Command{
28 28
 }
29 29
 
30 30
 func runMigrate(ctx *cli.Context) error {
31
-	m := &migrations.Migratory{
32
-		Client:     gitea.NewClient(ctx.String("url"), ctx.String("token")),
33
-		Private:    ctx.Bool("private"),
34
-		NewOwnerID: ctx.Int64("owner"),
35
-	}
36
-	if m.NewOwnerID == 0 {
37
-		usr, err := m.Client.GetMyUserInfo()
38
-		if err != nil {
39
-			return fmt.Errorf("cannot fetch user info about current user: %v", err)
40
-		}
41
-		m.NewOwnerID = usr.ID
42
-	}
43
-	c := context.Background()
31
+	onlyRepos := ctx.Bool("only-repo")
44 32
 	var gc *github.Client
45 33
 	if ctx.IsSet("gh-token") {
46 34
 		ts := oauth2.StaticTokenSource(
47 35
 			&oauth2.Token{AccessToken: ctx.String("gh-token")},
48 36
 		)
49
-		tc := oauth2.NewClient(c, ts)
37
+		tc := oauth2.NewClient(context.Background(), ts)
50 38
 		gc = github.NewClient(tc)
51 39
 	} else {
52 40
 		gc = github.NewClient(nil)
53 41
 	}
42
+	logrus.SetLevel(logrus.InfoLevel)
43
+	job := migrations.NewJob(&migrations.Options{
44
+		Private:    ctx.Bool("private"),
45
+		NewOwnerID: ctx.Int("owner"),
54 46
 
55
-	username := strings.Split(ctx.String("gh-repo"), "/")[0]
56
-	repo := strings.Split(ctx.String("gh-repo"), "/")[1]
57
-
58
-	return migrate(c, gc, m, username, repo, ctx.Bool("only-repo"))
59
-}
60
-
61
-func migrate(c context.Context, gc *github.Client, m *migrations.Migratory, username, repo string, onlyRepo bool) error {
62
-	fmt.Printf("Fetching repository %s/%s...\n", username, repo)
63
-	gr, _, err := gc.Repositories.Get(c, username, repo)
64
-	if err != nil {
65
-		return fmt.Errorf("error while fetching repo[%s/%s]: %v", username, repo, err)
66
-	}
67
-
68
-	fmt.Printf("Migrating repository %s/%s...\n", username, repo)
69
-	var mr *gitea.Repository
70
-	if mr, err = m.Repository(gr); err != nil {
71
-		return fmt.Errorf("error while migrating repo[%s/%s]: %v", username, repo, err)
72
-	}
73
-	fmt.Printf("Repository migrated to %s/%s\n", mr.Owner.UserName, mr.Name)
74
-
75
-	if onlyRepo {
76
-		return nil
77
-	}
78
-
79
-	fmt.Println("Fetching issues...")
80
-	opt := &github.IssueListByRepoOptions{
81
-		Sort:      "created",
82
-		Direction: "asc",
83
-		State:     "all",
84
-		ListOptions: github.ListOptions{
85
-			PerPage: 100,
86
-		},
87
-	}
88
-	var allIssues = make([]*github.Issue, 0)
89
-	for {
90
-		issues, resp, err := gc.Issues.ListByRepo(c, username, repo, opt)
47
+		Comments:     !onlyRepos,
48
+		Issues:       !onlyRepos,
49
+		Labels:       !onlyRepos,
50
+		Milestones:   !onlyRepos,
51
+		PullRequests: !onlyRepos,
52
+		Strategy:     migrations.Classic,
53
+	}, gitea.NewClient(ctx.String("url"), ctx.String("token")), gc, ctx.String("gh-repo"))
54
+	if job.Options.NewOwnerID == 0 {
55
+		usr, err := job.Client.GetMyUserInfo()
91 56
 		if err != nil {
92
-			return fmt.Errorf("error while listing repos: %v", err)
93
-		}
94
-		allIssues = append(allIssues, issues...)
95
-		if resp.NextPage == 0 {
96
-			break
57
+			return fmt.Errorf("cannot fetch user info about current user: %v", err)
97 58
 		}
98
-		opt.Page = resp.NextPage
59
+		job.Options.NewOwnerID = int(usr.ID)
99 60
 	}
100
-	fmt.Println("Migrating issues...")
101
-	for _, gi := range allIssues {
102
-		fmt.Printf("Migrating #%d...\n", *gi.Number)
103
-		issue, err := m.Issue(gi)
104
-		if err != nil {
105
-			return fmt.Errorf("migrating issue[id: %d]: %v", *gi.ID, err)
106
-		}
107
-		comments, _, err := gc.Issues.ListComments(c, username, repo, gi.GetNumber(), nil)
61
+	errs := job.StartMigration()
62
+	for err := range errs {
108 63
 		if err != nil {
109
-			return fmt.Errorf("fetching issue[id: %d] comments: %v", *gi.ID, err)
110
-		}
111
-		for _, gc := range comments {
112
-			fmt.Printf("-> %d...", gc.ID)
113
-			if _, err := m.IssueComment(issue, gc); err != nil {
114
-				return fmt.Errorf("migrating issue comment [issue: %d, comment: %d]: %v", *gi.ID, gc.ID, err)
115
-			}
116
-			fmt.Print("Done!\n")
64
+			return err
117 65
 		}
118
-		fmt.Printf("Migrated #%d...\n", *gi.Number)
119
-
120 66
 	}
121 67
 	return nil
122 68
 }

+ 0
- 23
cmd/migrate_test.go View File

@@ -1,23 +0,0 @@
1
-package cmd
2
-
3
-import (
4
-	"context"
5
-	"testing"
6
-
7
-	"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/migrations"
8
-	"github.com/google/go-github/github"
9
-	"github.com/stretchr/testify/assert"
10
-)
11
-
12
-// Test_migrate is an integration tests for migrate command
13
-// using repo JonasFranzDEV/test
14
-func Test_migrate(t *testing.T) {
15
-	assert.NoError(t, migrate(
16
-		context.Background(),
17
-		github.NewClient(nil),
18
-		migrations.DemoMigratory,
19
-		"JonasFranzDEV",
20
-		"test",
21
-		false,
22
-	))
23
-}

+ 48
- 0
cmd/web.go View File

@@ -0,0 +1,48 @@
1
+// +build web
2
+
3
+package cmd
4
+
5
+import (
6
+	"fmt"
7
+	"net/http"
8
+
9
+	"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/config"
10
+	"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/web"
11
+	"github.com/jinzhu/configor"
12
+	"github.com/sirupsen/logrus"
13
+	"github.com/urfave/cli"
14
+)
15
+
16
+// CmdWeb stars the web interface
17
+var CmdWeb = cli.Command{
18
+	Name:   "web",
19
+	Usage:  "Starts the web interface",
20
+	Action: runWeb,
21
+	Flags: []cli.Flag{
22
+		cli.StringFlag{
23
+			Name:   "c,config",
24
+			Usage:  "config file",
25
+			Value:  "config.yml",
26
+			EnvVar: "MIGRATOR_CONFIG",
27
+		},
28
+	},
29
+}
30
+
31
+func runWeb(ctx *cli.Context) error {
32
+	if err := configor.New(&configor.Config{ErrorOnUnmatchedKeys: true}).Load(&config.Config, ctx.String("config")); err != nil {
33
+		return err
34
+	}
35
+	r := web.InitRoutes()
36
+
37
+	hostname := config.Config.Web.Host
38
+	if len(hostname) == 0 {
39
+		hostname = "0.0.0.0"
40
+	}
41
+	port := config.Config.Web.Port
42
+	if port == 0 {
43
+		port = 4000
44
+	}
45
+	logrus.Infof("Server is running at http://%s:%d", hostname, port)
46
+	logrus.SetLevel(logrus.PanicLevel)
47
+	return http.ListenAndServe(fmt.Sprintf("%s:%d", hostname, port), r)
48
+}

+ 13
- 0
commands.go View File

@@ -0,0 +1,13 @@
1
+// +build !web
2
+
3
+package main
4
+
5
+import (
6
+	"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/cmd"
7
+	"github.com/urfave/cli"
8
+)
9
+
10
+var cmds = cli.Commands{
11
+	cmd.CmdMigrate,
12
+	cmd.CmdMigrateAll,
13
+}

+ 14
- 0
commands_web.go View File

@@ -0,0 +1,14 @@
1
+// +build web
2
+
3
+package main
4
+
5
+import (
6
+	"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/cmd"
7
+	"github.com/urfave/cli"
8
+)
9
+
10
+var cmds = cli.Commands{
11
+	cmd.CmdMigrate,
12
+	cmd.CmdMigrateAll,
13
+	cmd.CmdWeb,
14
+}

+ 8
- 0
config.example.yml View File

@@ -0,0 +1,8 @@
1
+# GitHub contains the OAuth2 application data obtainable from GitHub
2
+GitHub:
3
+  client_id: GITHUB_OAUTH_CLIENT_ID
4
+  client_secret: GITHUB_OAUTH_SECRET
5
+# Web contains the configuration for the integrated web server
6
+Web:
7
+  port: 4000
8
+  host: 0.0.0.0

+ 13
- 0
config/config.go View File

@@ -0,0 +1,13 @@
1
+package config
2
+
3
+// Config holds all configurations needed for web interface
4
+var Config = struct {
5
+	GitHub struct {
6
+		ClientID     string `required:"true" yaml:"client_id"`
7
+		ClientSecret string `required:"true" yaml:"client_secret"`
8
+	}
9
+	Web struct {
10
+		Host string `yaml:"host"`
11
+		Port int    `yaml:"port"`
12
+	}
13
+}{}

+ 2
- 6
main.go View File

@@ -1,3 +1,4 @@
1
+//go:generate swagger generate spec -i ./swagger.yml -o ./swagger.json
1 2
 package main
2 3
 
3 4
 import (
@@ -5,8 +6,6 @@ import (
5 6
 	"os"
6 7
 
7 8
 	"github.com/urfave/cli"
8
-
9
-	"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/cmd"
10 9
 )
11 10
 
12 11
 var (
@@ -20,10 +19,7 @@ func main() {
20 19
 	app.Version = fmt.Sprintf("%s+%s", version, build)
21 20
 	app.Usage = "GitHub to Gitea migrator for repositories"
22 21
 	app.Description = `Migrate your GitHub repositories including issues to Gitea`
23
-	app.Commands = cli.Commands{
24
-		cmd.CmdMigrate,
25
-		cmd.CmdMigrateAll,
26
-	}
22
+	app.Commands = cmds
27 23
 	if err := app.Run(os.Args); err != nil {
28 24
 		panic(err)
29 25
 	}

+ 264
- 0
migrations/github.go View File

@@ -0,0 +1,264 @@
1
+package migrations
2
+
3
+import (
4
+	"context"
5
+	"fmt"
6
+	"regexp"
7
+	"strconv"
8
+	"strings"
9
+	"sync"
10
+
11
+	"code.gitea.io/sdk/gitea"
12
+	"github.com/google/go-github/github"
13
+	"github.com/sirupsen/logrus"
14
+)
15
+
16
+// FetchMigratory adds GitHub fetching functions to migratory
17
+type FetchMigratory struct {
18
+	Migratory
19
+	GHClient  *github.Client
20
+	RepoOwner string
21
+	RepoName  string
22
+}
23
+
24
+func (fm *FetchMigratory) ctx() context.Context {
25
+	return context.Background()
26
+}
27
+
28
+// MigrateFromGitHub migrates RepoOwner/RepoName from GitHub to Gitea
29
+func (fm *FetchMigratory) MigrateFromGitHub() error {
30
+	fm.Status = &MigratoryStatus{
31
+		Stage: Importing,
32
+	}
33
+	logrus.WithFields(logrus.Fields{
34
+		"repo": fmt.Sprintf("%s/%s", fm.RepoOwner, fm.RepoName),
35
+	}).Info("migrating git repository")
36
+	ghRepo, _, err := fm.GHClient.Repositories.Get(fm.ctx(), fm.RepoOwner, fm.RepoName)
37
+	if err != nil {
38
+		fm.Status.Stage = Failed
39
+		fm.Status.FatalError = err
40
+		return fmt.Errorf("GHClient Repostiories Get: %v", err)
41
+	}
42
+	fm.repository, err = fm.Repository(ghRepo)
43
+	if err != nil {
44
+		fm.Status.Stage = Failed
45
+		fm.Status.FatalError = err
46
+		return fmt.Errorf("Repository migration: %v", err)
47
+	}
48
+	logrus.WithFields(logrus.Fields{
49
+		"repo": fmt.Sprintf("%s/%s", fm.RepoOwner, fm.RepoName),
50
+	}).Info("git repository migrated")
51
+	if fm.Options.Issues || fm.Options.PullRequests {
52
+		var commentsChan chan *[]*github.IssueComment
53
+		if fm.Options.Comments {
54
+			commentsChan = fm.fetchCommentsAsync()
55
+		}
56
+		issues, err := fm.FetchIssues()
57
+		if err != nil {
58
+			fm.Status.Stage = Failed
59
+			fm.Status.FatalError = err
60
+			logrus.WithFields(logrus.Fields{
61
+				"repo": fmt.Sprintf("%s/%s", fm.RepoOwner, fm.RepoName),
62
+			}).Fatalf("migration failed: %v", fm.Status.FatalError)
63
+			return err
64
+		}
65
+		fm.Status.Stage = Migrating
66
+		fm.Status.Issues = int64(len(issues))
67
+		migratedIssues := make(map[int]*gitea.Issue)
68
+		for _, issue := range issues {
69
+			if (!issue.IsPullRequest() || fm.Options.PullRequests) &&
70
+				(issue.IsPullRequest() || fm.Options.Issues) {
71
+				migratedIssues[issue.GetNumber()], err = fm.Issue(issue)
72
+				if err != nil {
73
+					fm.Status.IssuesError++
74
+					logrus.WithFields(logrus.Fields{
75
+						"repo":  fmt.Sprintf("%s/%s", fm.RepoOwner, fm.RepoName),
76
+						"issue": issue.GetNumber(),
77
+					}).Warnf("error while migrating: %v", err)
78
+					continue
79
+				}
80
+				fm.Status.IssuesMigrated++
81
+				logrus.WithFields(logrus.Fields{
82
+					"repo":  fmt.Sprintf("%s/%s", fm.RepoOwner, fm.RepoName),
83
+					"issue": issue.GetNumber(),
84
+				}).Info("issue migrated")
85
+			} else {
86
+				fm.Status.Issues--
87
+			}
88
+		}
89
+		if fm.Options.Comments {
90
+			var comments []*github.IssueComment
91
+			if cmts := <-commentsChan; cmts == nil {
92
+				fm.Status.Stage = Failed
93
+				err := fmt.Errorf("error while fetching issue comments")
94
+				logrus.WithFields(logrus.Fields{
95
+					"repo": fmt.Sprintf("%s/%s", fm.RepoOwner, fm.RepoName),
96
+				}).Fatalf("migration failed: %v", fm.Status.FatalError)
97
+				return err
98
+			} else {
99
+				comments = *cmts
100
+			}
101
+
102
+			if err != nil {
103
+				fm.Status.Stage = Failed
104
+				fm.Status.FatalError = err
105
+				logrus.WithFields(logrus.Fields{
106
+					"repo": fmt.Sprintf("%s/%s", fm.RepoOwner, fm.RepoName),
107
+				}).Fatalf("migration failed: %v", fm.Status.FatalError)
108
+				return err
109
+			}
110
+			fm.Status.Comments = int64(len(comments))
111
+			commentsByIssue := make(map[*gitea.Issue][]*github.IssueComment, len(migratedIssues))
112
+			for _, comment := range comments {
113
+				issueIndex, err := getIssueIndexFromHTMLURL(comment.GetHTMLURL())
114
+				if err != nil {
115
+					fm.Status.CommentsError++
116
+					logrus.WithFields(logrus.Fields{
117
+						"repo":    fmt.Sprintf("%s/%s", fm.RepoOwner, fm.RepoName),
118
+						"issue":   issueIndex,
119
+						"comment": comment.GetID(),
120
+					}).Warnf("error while migrating comment: %v", err)
121
+					continue
122
+				}
123
+				if issue, ok := migratedIssues[issueIndex]; ok && issue != nil {
124
+					if list, ok := commentsByIssue[issue]; !ok && list != nil {
125
+						commentsByIssue[issue] = []*github.IssueComment{comment}
126
+					} else {
127
+						commentsByIssue[issue] = append(list, comment)
128
+					}
129
+				} else {
130
+					fm.Status.CommentsError++
131
+					continue
132
+				}
133
+			}
134
+			wg := sync.WaitGroup{}
135
+			for issue, comms := range commentsByIssue {
136
+				wg.Add(1)
137
+				go func(i *gitea.Issue, cs []*github.IssueComment) {
138
+					for _, comm := range cs {
139
+						if _, err := fm.IssueComment(i, comm); err != nil {
140
+							fm.Status.CommentsError++
141
+							logrus.WithFields(logrus.Fields{
142
+								"repo":    fmt.Sprintf("%s/%s", fm.RepoOwner, fm.RepoName),
143
+								"comment": comm.GetID(),
144
+							}).Warnf("error while migrating comment: %v", err)
145
+							continue
146
+						}
147
+						fm.Status.CommentsMigrated++
148
+						logrus.WithFields(logrus.Fields{
149
+							"repo":    fmt.Sprintf("%s/%s", fm.RepoOwner, fm.RepoName),
150
+							"comment": comm.GetID(),
151
+						}).Info("comment migrated")
152
+					}
153
+					wg.Done()
154
+				}(issue, comms)
155
+			}
156
+			wg.Wait()
157
+		}
158
+	}
159
+	if fm.Status.FatalError != nil {
160
+		fm.Status.Stage = Failed
161
+		logrus.WithFields(logrus.Fields{
162
+			"repo": fmt.Sprintf("%s/%s", fm.RepoOwner, fm.RepoName),
163
+		}).Fatalf("migration failed: %v", fm.Status.FatalError)
164
+		return nil
165
+	}
166
+	fm.Status.Stage = Finished
167
+	logrus.WithFields(logrus.Fields{
168
+		"repo": fmt.Sprintf("%s/%s", fm.RepoOwner, fm.RepoName),
169
+	}).Info("migration successful")
170
+	return nil
171
+}
172
+
173
+var issueIndexRegex = regexp.MustCompile(`/(issues|pull)/([0-9]+)#`)
174
+
175
+func getIssueIndexFromHTMLURL(htmlURL string) (int, error) {
176
+	// Alt is 4 times faster but more error prune
177
+	if res, err := getIssueIndexFromHTMLURLAlt(htmlURL); err == nil {
178
+		return res, nil
179
+	}
180
+	matches := issueIndexRegex.FindStringSubmatch(htmlURL)
181
+	if len(matches) < 3 {
182
+		return 0, fmt.Errorf("cannot parse issue id from HTML URL: %s", htmlURL)
183
+	}
184
+	return strconv.Atoi(matches[2])
185
+}
186
+func getIssueIndexFromHTMLURLAlt(htmlURL string) (int, error) {
187
+	res := strings.Split(htmlURL, "/issues/")
188
+	if len(res) != 2 {
189
+		res = strings.Split(htmlURL, "/pull/")
190
+	}
191
+	if len(res) != 2 {
192
+		return 0, fmt.Errorf("invalid HTMLURL: %s", htmlURL)
193
+	}
194
+	number := res[1]
195
+	number = strings.Split(number, "#")[0]
196
+	return strconv.Atoi(number)
197
+}
198
+
199
+// FetchIssues fetches all issues from GitHub
200
+func (fm *FetchMigratory) FetchIssues() ([]*github.Issue, error) {
201
+	opt := &github.IssueListByRepoOptions{
202
+		Sort:      "created",
203
+		Direction: "asc",
204
+		State:     "all",
205
+		ListOptions: github.ListOptions{
206
+			PerPage: 100,
207
+		},
208
+	}
209
+	var allIssues = make([]*github.Issue, 0)
210
+	for {
211
+		issues, resp, err := fm.GHClient.Issues.ListByRepo(fm.ctx(), fm.RepoOwner, fm.RepoName, opt)
212
+		if err != nil {
213
+			return nil, fmt.Errorf("error while listing repos: %v", err)
214
+		}
215
+		allIssues = append(allIssues, issues...)
216
+		if resp.NextPage == 0 {
217
+			break
218
+		}
219
+		opt.Page = resp.NextPage
220
+	}
221
+	return allIssues, nil
222
+}
223
+
224
+// FetchComments fetches all comments from GitHub
225
+func (fm *FetchMigratory) FetchComments() ([]*github.IssueComment, error) {
226
+	var allComments = make([]*github.IssueComment, 0)
227
+	opt := &github.IssueListCommentsOptions{
228
+		Sort:      "created",
229
+		Direction: "asc",
230
+		ListOptions: github.ListOptions{
231
+			PerPage: 100,
232
+		},
233
+	}
234
+	for {
235
+		comments, resp, err := fm.GHClient.Issues.ListComments(fm.ctx(), fm.RepoOwner, fm.RepoName, 0, opt)
236
+		if err != nil {
237
+			return nil, fmt.Errorf("error while listing repos: %v", err)
238
+		}
239
+		allComments = append(allComments, comments...)
240
+		if resp.NextPage == 0 {
241
+			break
242
+		}
243
+		opt.Page = resp.NextPage
244
+	}
245
+	return allComments, nil
246
+}
247
+
248
+func (fm *FetchMigratory) fetchCommentsAsync() chan *[]*github.IssueComment {
249
+	ret := make(chan *[]*github.IssueComment, 1)
250
+	go func(f *FetchMigratory) {
251
+		comments, err := f.FetchComments()
252
+		if err != nil {
253
+			f.Status.FatalError = err
254
+			ret <- nil
255
+			logrus.WithFields(logrus.Fields{
256
+				"repo": fmt.Sprintf("%s/%s", fm.RepoOwner, fm.RepoName),
257
+			}).Fatalf("fetching comments failed: %v", fm.Status.FatalError)
258
+			return
259
+		}
260
+		f.Status.Comments = int64(len(comments))
261
+		ret <- &comments
262
+	}(fm)
263
+	return ret
264
+}

+ 55
- 0
migrations/github_test.go View File

@@ -0,0 +1,55 @@
1
+package migrations
2
+
3
+import (
4
+	"testing"
5
+
6
+	"github.com/google/go-github/github"
7
+	"github.com/stretchr/testify/assert"
8
+)
9
+
10
+func BenchmarkGetIssueIndexFromHTMLURLAlt(b *testing.B) {
11
+	for i := 0; i <= b.N; i++ {
12
+		getIssueIndexFromHTMLURLAlt("https://github.com/octocat/Hello-World/issues/1347#issuecomment-1")
13
+	}
14
+}
15
+
16
+func TestGetIssueIndexFromHTMLURLAlt(t *testing.T) {
17
+	res, err := getIssueIndexFromHTMLURLAlt("https://github.com/octocat/Hello-World/issues/1347#issuecomment-1")
18
+	assert.NoError(t, err)
19
+	assert.Equal(t, 1347, res)
20
+	res, err = getIssueIndexFromHTMLURLAlt("https://github.com/oment-1")
21
+	assert.Error(t, err)
22
+}
23
+
24
+func BenchmarkGetIssueIndexFromHTMLURL(b *testing.B) {
25
+	for i := 0; i <= b.N; i++ {
26
+		getIssueIndexFromHTMLURL("https://github.com/octocat/Hello-World/issues/1347#issuecomment-1")
27
+	}
28
+}
29
+
30
+func TestGetIssueIndexFromHTMLURL(t *testing.T) {
31
+	res, err := getIssueIndexFromHTMLURL("https://github.com/octocat/Hello-World/issues/1347#issuecomment-1")
32
+	assert.NoError(t, err)
33
+	assert.Equal(t, 1347, res)
34
+	res, err = getIssueIndexFromHTMLURL("https://github.com/oment-1")
35
+	assert.Error(t, err)
36
+}
37
+
38
+var testFMig = &FetchMigratory{
39
+	Migratory: *DemoMigratory,
40
+	GHClient:  github.NewClient(nil),
41
+	RepoOwner: "JonasFranzDEV",
42
+	RepoName:  "test",
43
+}
44
+
45
+func TestFetchMigratory_FetchIssues(t *testing.T) {
46
+	issues, err := testFMig.FetchIssues()
47
+	assert.NoError(t, err)
48
+	assert.True(t, len(issues) > 0, "at least one issue found")
49
+}
50
+
51
+func TestFetchMigratory_FetchComments(t *testing.T) {
52
+	comments, err := testFMig.FetchIssues()
53
+	assert.NoError(t, err)
54
+	assert.True(t, len(comments) > 0, "at least one comment found")
55
+}

+ 9
- 5
migrations/issue.go View File

@@ -18,7 +18,7 @@ func (m *Migratory) Issue(gi *github.Issue) (*gitea.Issue, error) {
18 18
 
19 19
 	// Migrate milestone if it is not already migrated
20 20
 	milestone := int64(0)
21
-	if gi.Milestone != nil {
21
+	if gi.Milestone != nil && m.Options.Milestones {
22 22
 		// Lookup if milestone is already migrated
23 23
 		if migratedMilestone, ok := m.migratedMilestones[*gi.Milestone.ID]; ok {
24 24
 			milestone = migratedMilestone
@@ -28,10 +28,14 @@ func (m *Migratory) Issue(gi *github.Issue) (*gitea.Issue, error) {
28 28
 			milestone = ms.ID
29 29
 		}
30 30
 	}
31
-	// Migrate labels
32
-	labels, err := m.labels(gi.Labels)
33
-	if err != nil {
34
-		return nil, err
31
+	var labels = make([]int64, 0)
32
+	var err error
33
+	if m.Options.Labels {
34
+		// Migrate labels
35
+		labels, err = m.labels(gi.Labels)
36
+		if err != nil {
37
+			return nil, err
38
+		}
35 39
 	}
36 40
 
37 41
 	return m.Client.CreateIssue(m.repository.Owner.UserName, m.repository.Name,

+ 2
- 2
migrations/issue_test.go View File

@@ -28,7 +28,7 @@ func TestMigratory_Label(t *testing.T) {
28 28
 		Name:  github.String("testlabel"),
29 29
 		Color: github.String("123456"),
30 30
 	})
31
-	assert.NoError(t, err)
31
+	assertNoError(t, err)
32 32
 	assert.Equal(t, "123456", res.Color)
33 33
 	assert.Equal(t, "testlabel", res.Name)
34 34
 }
@@ -41,7 +41,7 @@ func TestMigratory_Milestone(t *testing.T) {
41 41
 		Title:       github.String("TEST"),
42 42
 		DueOn:       &demoTime,
43 43
 	})
44
-	assert.NoError(t, err)
44
+	assertNoError(t, err)
45 45
 	assert.Equal(t, "TEST", res.Title)
46 46
 	assert.Equal(t, "test milestone", res.Description)
47 47
 	assert.Equal(t, demoTime.Unix(), res.Deadline.Unix())

+ 114
- 0
migrations/job.go View File

@@ -0,0 +1,114 @@
1
+package migrations
2
+
3
+import (
4
+	"fmt"
5
+	"strings"
6
+
7
+	"code.gitea.io/sdk/gitea"
8
+	"github.com/google/go-github/github"
9
+)
10
+
11
+// Job manages all migrations of a "migartion job"
12
+type Job struct {
13
+	Repositories []string
14
+	Options      *Options
15
+	Client       *gitea.Client
16
+	GHClient     *github.Client
17
+
18
+	migratories map[string]*Migratory
19
+}
20
+
21
+// JobReport represents the current status of a Job
22
+type JobReport struct {
23
+	Pending  []string                    `json:"pending"`
24
+	Running  map[string]*MigratoryStatus `json:"running"`
25
+	Finished map[string]*MigratoryStatus `json:"finished"`
26
+	Failed   map[string]string           `json:"failed"`
27
+}
28
+
29
+// NewJob returns an instance of initialized instance of Job
30
+func NewJob(options *Options, client *gitea.Client, githubClient *github.Client, repos ...string) *Job {
31
+	return &Job{Repositories: repos, Options: options, Client: client, GHClient: githubClient}
32
+}
33
+
34
+// StatusReport generates a JobReport indicating which state the job is
35
+func (job *Job) StatusReport() *JobReport {
36
+	report := &JobReport{
37
+		Pending:  make([]string, 0),
38
+		Finished: make(map[string]*MigratoryStatus),
39
+		Running:  make(map[string]*MigratoryStatus),
40
+		Failed:   make(map[string]string),
41
+	}
42
+	for _, repo := range job.Repositories {
43
+		if migratory, ok := job.migratories[repo]; ok {
44
+			switch migratory.Status.Stage {
45
+			case Finished:
46
+				report.Finished[repo] = migratory.Status
47
+			case Importing:
48
+			case Migrating:
49
+				report.Running[repo] = migratory.Status
50
+			case Failed:
51
+				report.Failed[repo] = migratory.Status.FatalError.Error()
52
+			default:
53
+				report.Pending = append(report.Pending, repo)
54
+				fmt.Printf("unknown status %d\n", migratory.Status.Stage)
55
+			}
56
+		} else {
57
+			report.Pending = append(report.Pending, repo)
58
+		}
59
+	}
60
+	return report
61
+}
62
+
63
+// StartMigration migrates all repos from Repositories
64
+func (job *Job) StartMigration() chan error {
65
+	errs := make(chan error, len(job.Repositories))
66
+	var pendingRepos = len(job.Repositories)
67
+	autoclose := func() {
68
+		pendingRepos--
69
+		if pendingRepos <= 0 {
70
+			close(errs)
71
+		}
72
+	}
73
+	job.migratories = make(map[string]*Migratory, pendingRepos)
74
+	for _, repo := range job.Repositories {
75
+		mig, err := job.initFetchMigratory(repo)
76
+		job.migratories[repo] = &mig.Migratory
77
+		if err != nil {
78
+			mig.Status = &MigratoryStatus{
79
+				Stage:      Failed,
80
+				FatalError: err,
81
+			}
82
+			errs <- err
83
+			autoclose()
84
+			continue
85
+		}
86
+		go func() {
87
+			err := mig.MigrateFromGitHub()
88
+			errs <- err
89
+			autoclose()
90
+		}()
91
+	}
92
+	return errs
93
+}
94
+
95
+func (job *Job) initFetchMigratory(repo string) (*FetchMigratory, error) {
96
+	res := strings.Split(repo, "/")
97
+	if len(res) != 2 {
98
+		return nil, fmt.Errorf("invalid repo name: %s", repo)
99
+	}
100
+	return &FetchMigratory{
101
+		Migratory: Migratory{
102
+			Client:  job.Client,
103
+			Options: *job.Options,
104
+		},
105
+		RepoName:  res[1],
106
+		RepoOwner: res[0],
107
+		GHClient:  job.GHClient,
108
+	}, nil
109
+}
110
+
111
+// Finished indicates if the job is finished or not
112
+func (job *Job) Finished() bool {
113
+	return (len(job.StatusReport().Failed) + len(job.StatusReport().Finished)) >= len(job.Repositories)
114
+}

+ 66
- 0
migrations/job_test.go View File

@@ -0,0 +1,66 @@
1
+package migrations
2
+
3
+import (
4
+	"fmt"
5
+	"testing"
6
+
7
+	"github.com/stretchr/testify/assert"
8
+)
9
+
10
+func TestJob_StatusReport(t *testing.T) {
11
+	jobWithStatus := func(status *MigratoryStatus) *Job {
12
+		return &Job{
13
+			migratories: map[string]*Migratory{
14
+				"test/test": {
15
+					Status: status,
16
+				},
17
+			},
18
+			Repositories: []string{
19
+				"test/test",
20
+			},
21
+		}
22
+	}
23
+	// Pending
24
+	pendingJob := &Job{
25
+		Repositories: []string{
26
+			"test/test",
27
+		},
28
+	}
29
+	report := pendingJob.StatusReport()
30
+	assert.Len(t, report.Pending, 1)
31
+	assert.Equal(t, report.Pending[0], "test/test")
32
+	assert.Len(t, report.Failed, 0)
33
+	assert.Len(t, report.Running, 0)
34
+	assert.Len(t, report.Finished, 0)
35
+
36
+	// Finished
37
+	report = jobWithStatus(&MigratoryStatus{
38
+		Stage: Finished,
39
+	}).StatusReport()
40
+	assert.Len(t, report.Pending, 0)
41
+	assert.Len(t, report.Failed, 0)
42
+	assert.Len(t, report.Running, 0)
43
+	assert.Len(t, report.Finished, 1)
44
+	assert.Equal(t, Finished, report.Finished["test/test"].Stage)
45
+
46
+	// Failed
47
+	report = jobWithStatus(&MigratoryStatus{
48
+		Stage:      Failed,
49
+		FatalError: fmt.Errorf("test"),
50
+	}).StatusReport()
51
+	assert.Len(t, report.Failed, 1)
52
+	assert.Equal(t, "test", report.Failed["test/test"])
53
+	assert.Len(t, report.Pending, 0)
54
+	assert.Len(t, report.Running, 0)
55
+	assert.Len(t, report.Finished, 0)
56
+
57
+	// Running
58
+	report = jobWithStatus(&MigratoryStatus{
59
+		Stage: Migrating,
60
+	}).StatusReport()
61
+	assert.Len(t, report.Running, 1)
62
+	assert.Equal(t, Migrating, report.Running["test/test"].Stage)
63
+	assert.Len(t, report.Pending, 0)
64
+	assert.Len(t, report.Failed, 0)
65
+	assert.Len(t, report.Finished, 0)
66
+}

+ 33
- 5
migrations/migratory.go View File

@@ -2,14 +2,26 @@ package migrations
2 2
 
3 3
 import "code.gitea.io/sdk/gitea"
4 4
 
5
+// MigratoryStage represents the actual step in the process
6
+type MigratoryStage int
7
+
8
+const (
9
+	// Importing imports the repo to Gitea
10
+	Importing MigratoryStage = iota
11
+	// Migrating migrates issues, etc to Gitea
12
+	Migrating
13
+	// Finished means that everything is migrated successfully
14
+	Finished
15
+	// Failed is only entered if a fatal error occurs
16
+	Failed
17
+)
18
+
5 19
 // Migratory is the context for migrating things from GitHub to Gitea
6 20
 type Migratory struct {
7
-	Client       *gitea.Client
8
-	AuthUsername string
9
-	AuthPassword string
21
+	Options
22
+	Client *gitea.Client
10 23
 
11
-	Private    bool
12
-	NewOwnerID int64
24
+	Status *MigratoryStatus
13 25
 
14 26
 	repository *gitea.Repository
15 27
 	// key: github milestone id | value: gitea milestone id
@@ -17,3 +29,19 @@ type Migratory struct {
17 29
 	// key: github label id | value: gitea label id
18 30
 	migratedLabels map[int64]int64
19 31
 }
32
+
33
+// MigratoryStatus represents the actual state of a migratory
34
+type MigratoryStatus struct {
35
+	Stage MigratoryStage `json:"stage"`
36
+
37
+	Issues         int64 `json:"total_issues"`
38
+	IssuesMigrated int64 `json:"migrated_issues"`
39
+	IssuesError    int64 `json:"failed_issues"`
40
+
41
+	Comments         int64 `json:"total_comments"`
42
+	CommentsError    int64 `json:"failed_comments"`
43
+	CommentsMigrated int64 `json:"migrated_comments"`
44
+
45
+	// FatalError should only be used if stage == failed; indicates which fatal error occurred
46
+	FatalError error
47
+}

+ 32
- 0
migrations/options.go View File

@@ -0,0 +1,32 @@
1
+package migrations
2
+
3
+// Options defines the way a repository gets migrated
4
+type Options struct {
5
+	Issues       bool
6
+	Milestones   bool
7
+	Labels       bool
8
+	Comments     bool
9
+	PullRequests bool
10
+
11
+	AuthUsername string
12
+	AuthPassword string
13
+
14
+	Private    bool
15
+	NewOwnerID int
16
+
17
+	Strategy Strategy
18
+}
19
+
20
+// Strategy represents the procedure of migration.
21
+type Strategy int
22
+
23
+const (
24
+	// Classic works for all Gitea versions and creates comments by the user migrating the repository. This does not require
25
+	// admin permissions. The issue "number" is also assinged by Gitea and could be different to the GitHub issue "number".
26
+	// Creation date of comments, issues, milestones, etc. will be the date of creation.
27
+	Classic Strategy = iota
28
+	// Advanced works for all Gitea versions 1.6+ and utilizes the Gitea Migration API which allows the tool to create comments
29
+	// with Ghost Users. Creation date and issue numbers will be the same like GitHub. It requires admin permissions for repo
30
+	// (creation date, issue number) and/or
31
+	Advanced
32
+)

+ 18
- 4
migrations/utils.go View File

@@ -1,24 +1,38 @@
1 1
 package migrations
2 2
 
3 3
 import (
4
+	"strings"
5
+	"testing"
4 6
 	"time"
5 7
 
6 8
 	"code.gitea.io/sdk/gitea"
9
+	"github.com/stretchr/testify/assert"
7 10
 )
8 11
 
12
+// DemoMigratory is been used for testing
9 13
 var DemoMigratory = &Migratory{
10
-	AuthUsername: "demo",
11
-	AuthPassword: "demo",
12
-	Client:       gitea.NewClient("http://gitea:3000", "8bffa364d5a4b2f18421426da0baf6ccddd16d6b"),
14
+	Options: Options{
15
+		AuthUsername: "demo",
16
+		AuthPassword: "demo",
17
+		NewOwnerID:   1,
18
+	},
19
+	Client: gitea.NewClient("http://gitea:3000", "8bffa364d5a4b2f18421426da0baf6ccddd16d6b"),
13 20
 	repository: &gitea.Repository{
14 21
 		Name: "demo",
15 22
 		Owner: &gitea.User{
16 23
 			UserName: "demo",
17 24
 		},
18 25
 	},
19
-	NewOwnerID:         1,
20 26
 	migratedMilestones: make(map[int64]int64),
21 27
 	migratedLabels:     make(map[int64]int64),
22 28
 }
23 29
 
24 30
 var demoTime = time.Date(2018, 01, 01, 01, 01, 01, 01, time.UTC)
31
+
32
+func assertNoError(t *testing.T, err error) {
33
+	if err != nil && strings.Contains(err.Error(), "lookup gitea") {
34
+		t.Skip("gitea instance is not running")
35
+	} else {
36
+		assert.NoError(t, err)
37
+	}
38
+}

+ 50
- 0
web/auth/gitea.go View File

@@ -0,0 +1,50 @@
1
+package auth
2
+
3
+import (
4
+	"code.gitea.io/sdk/gitea"
5
+	"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/web/context"
6
+)
7
+
8
+// GiteaLoginForm represents the data required for logging in into gitea
9
+type GiteaLoginForm struct {
10
+	Username    string `form:"username"`
11
+	Password    string `form:"password"`
12
+	AccessToken string `form:"access-token"`
13
+	GiteaURL    string `form:"gitea-url"`
14
+	Type        string `form:"use" binding:"Required;In(token,password)"`
15
+}
16
+
17
+// LoginToGitea handles the POST request for signing in with a Gitea account
18
+func LoginToGitea(ctx *context.Context, form GiteaLoginForm) {
19
+	var token string
20
+	if form.Type == "password" {
21
+		client := gitea.NewClient(form.GiteaURL, "")
22
+		tkn, err := client.CreateAccessToken(form.Username, form.Password, gitea.CreateAccessTokenOption{
23
+			Name: "gitea-github-migrator",
24
+		})
25
+		if err != nil {
26
+			ctx.Flash.Error("Cannot create access token please check your credentials!")
27
+			ctx.Redirect("/")
28
+			return
29
+		}
30
+		token = tkn.Sha1
31
+	} else {
32
+		token = form.AccessToken
33
+	}
34
+	client := gitea.NewClient(form.GiteaURL, token)
35
+	usr, err := client.GetMyUserInfo()
36
+	if err != nil {
37
+		ctx.Flash.Error("Invalid Gitea credentials.")
38
+		ctx.Redirect("/")
39
+		return
40
+	}
41
+	ctx.Session.Set("gitea_user", &context.User{
42
+		ID:        usr.ID,
43
+		Username:  usr.UserName,
44
+		Token:     token,
45
+		AvatarURL: usr.AvatarURL,
46
+	})
47
+	ctx.Session.Set("gitea", form.GiteaURL)
48
+	ctx.Redirect("/")
49
+	return
50
+}

+ 68
- 0
web/auth/github.go View File

@@ -0,0 +1,68 @@
1
+package auth
2
+
3
+import (
4
+	"context"
5
+	"net/http"
6
+
7
+	"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/config"
8
+	webcontext "git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/web/context"
9
+	"github.com/adam-hanna/randomstrings"
10
+	"github.com/go-macaron/session"
11
+	"github.com/google/go-github/github"
12
+	"golang.org/x/oauth2"
13
+	githubauth "golang.org/x/oauth2/github"
14
+)
15
+
16
+var (
17
+	githubOAuthConfig *oauth2.Config
18
+)
19
+
20
+// InitGitHubOAuthConfig loads values from config into githubOAuthConfig
21
+func InitGitHubOAuthConfig() {
22
+	githubOAuthConfig = &oauth2.Config{
23
+		ClientID:     config.Config.GitHub.ClientID,
24
+		ClientSecret: config.Config.GitHub.ClientSecret,
25
+		Scopes:       []string{"repo"},
26
+		Endpoint:     githubauth.Endpoint,
27
+	}
28
+}
29
+
30
+// RedirectToGitHub returns the redirect URL for github
31
+func RedirectToGitHub(ctx *webcontext.Context, session session.Store) {
32
+	state, err := randomstrings.GenerateRandomString(64)
33
+	if err != nil {
34
+		return
35
+	}
36
+	session.Set("state", state)
37
+	ctx.Redirect(githubOAuthConfig.AuthCodeURL(state), http.StatusTemporaryRedirect)
38
+}
39
+
40
+// CallbackFromGitHub handles the callback from the GitHub OAuth provider
41
+func CallbackFromGitHub(ctx *webcontext.Context, session session.Store) {
42
+	bg := context.Background()
43
+	var state string
44
+	var ok bool
45
+	if state, ok = session.Get("state").(string); state == "" || !ok || state != ctx.Query("state") {
46
+		ctx.Handle(400, "invalid session", nil)
47
+		return
48
+	}
49
+	token, err := githubOAuthConfig.Exchange(bg, ctx.Query("code"))
50
+	if err != nil {
51
+		ctx.Handle(403, "access denied", err)
52
+		return
53
+	}
54
+	tc := oauth2.NewClient(bg, oauth2.StaticTokenSource(token))
55
+	client := github.NewClient(tc)
56
+	user, _, err := client.Users.Get(bg, "")
57
+	if err != nil {
58
+		ctx.Handle(403, "access denied", err)
59
+		return
60
+	}
61
+	session.Set("user", &webcontext.User{
62
+		ID:        user.GetID(),
63
+		AvatarURL: *user.AvatarURL,
64
+		Username:  user.GetLogin(),
65
+		Token:     token.AccessToken,
66
+	})
67
+	ctx.Redirect("/")
68
+}

+ 115
- 0
web/context/context.go View File

@@ -0,0 +1,115 @@
1
+package context
2
+
3
+import (
4
+	bgctx "context"
5
+	"fmt"
6
+	"strings"
7
+
8
+	"code.gitea.io/sdk/gitea"
9
+	"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/config"
10
+	"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/migrations"
11
+	"github.com/go-macaron/session"
12
+	"github.com/google/go-github/github"
13
+	"github.com/sirupsen/logrus"
14
+	"golang.org/x/oauth2"
15
+	"gopkg.in/macaron.v1"
16
+)
17
+
18
+// Context represents context of a request.
19
+type Context struct {
20
+	*macaron.Context
21
+	Flash   *session.Flash
22
+	Session session.Store
23
+
24
+	Client      *github.Client
25
+	GiteaClient *gitea.Client
26
+	User        *User //GitHub user
27
+	GiteaUser   *User
28
+	Link        string // current request URL
29
+}
30
+
31
+// User is an abstraction of a Gitea or GitHub user, saving the required information
32
+type User struct {
33
+	ID        int64
34
+	Username  string
35
+	AvatarURL string
36
+	Token     string
37
+}
38
+
39
+var runningJobs = make(map[string]*migrations.Job)
40
+
41
+// GetCurrentJob returns the current job of the user
42
+// Bug(JonasFranzDEV): prevents scalability (FIXME)
43
+func (ctx *Context) GetCurrentJob() *migrations.Job {
44
+	return runningJobs[ctx.Session.ID()]
45
+}
46
+
47
+// SetCurrentJob sets the current job of the user
48
+// Bug(JonasFranzDEV): prevents scalability (FIXME)
49
+func (ctx *Context) SetCurrentJob(job *migrations.Job) {
50
+	runningJobs[ctx.Session.ID()] = job
51
+}
52
+
53
+// Handle displays the corresponding error message
54
+func (ctx *Context) Handle(status int, title string, err error) {
55
+	if err != nil {
56
+		if macaron.Env != macaron.PROD {
57
+			ctx.Data["ErrorMsg"] = err
58
+		}
59
+	}
60
+	logrus.Warnf("Handle: %v", err)
61
+	ctx.Data["ErrTitle"] = title
62
+
63
+	switch status {
64
+	case 403:
65
+		ctx.Data["Title"] = "Access denied"
66
+	case 404:
67
+		ctx.Data["Title"] = "Page not found"
68
+	case 500:
69
+		ctx.Data["Title"] = "Internal Server Error"
70
+	default:
71
+		ctx.Context.HTML(status, "status/unknown_error")
72
+		return
73
+	}
74
+	ctx.Context.HTML(status, fmt.Sprintf("status/%d", status))
75
+}
76
+
77
+// Contexter injects context.Context into macaron
78
+func Contexter() macaron.Handler {
79
+	return func(c *macaron.Context, sess session.Store, f *session.Flash) {
80
+		ctx := &Context{
81
+			Context: c,
82
+			Flash:   f,
83
+			Session: sess,
84
+			Link:    c.Req.URL.String(),
85
+		}
86
+		c.Data["Link"] = ctx.Link
87
+		if ctx.Req.Method == "POST" && strings.Contains(ctx.Req.Header.Get("Content-Type"), "multipart/form-data") {
88
+			if err := ctx.Req.ParseMultipartForm(5242880); err != nil &&
89
+				strings.Contains(err.Error(), "EOF") {
90
+				ctx.Handle(500, "ParseMultipartForm", err)
91
+			}
92
+		}
93
+		ctx.Data["Config"] = config.Config
94
+		usr := sess.Get("user")
95
+		if usr != nil {
96
+			ctx.User = usr.(*User)
97
+			ctx.Data["User"] = ctx.User
98
+		}
99
+		giteaUsr := sess.Get("gitea_user")
100
+		if giteaUsr != nil {
101
+			ctx.GiteaUser = giteaUsr.(*User)
102
+			ctx.Data["GiteaUser"] = ctx.GiteaUser
103
+		}
104
+		if ctx.User != nil && ctx.User.Token != "" {
105
+			tc := oauth2.NewClient(bgctx.Background(), oauth2.StaticTokenSource(&oauth2.Token{AccessToken: ctx.User.Token}))
106
+			ctx.Client = github.NewClient(tc)
107
+		} else {
108
+			ctx.Client = github.NewClient(nil)
109
+		}
110
+		if giteaURL, ok := sess.Get("gitea").(string); ok && giteaURL != "" && ctx.GiteaUser != nil && ctx.GiteaUser.Token != "" {
111
+			ctx.GiteaClient = gitea.NewClient(giteaURL, ctx.GiteaUser.Token)
112
+		}
113
+		c.Map(ctx)
114
+	}
115
+}

+ 58
- 0
web/fs.go View File

@@ -0,0 +1,58 @@
1
+package web
2
+
3
+import (
4
+	"bytes"
5
+	"io"
6
+	"path"
7
+	"strings"
8
+
9
+	"github.com/gobuffalo/packr"
10
+	"gopkg.in/macaron.v1"
11
+)
12
+
13
+// BundledFS implements ServeFileSystem for packr.Box
14
+type BundledFS struct {
15
+	packr.Box
16
+}
17
+
18
+// Exists returns true if filepath exists
19
+func (fs *BundledFS) Exists(prefix string, filepath string) bool {
20
+	if p := strings.TrimPrefix(filepath, prefix); len(p) < len(filepath) {
21
+		return fs.Has(p)
22
+	}
23
+	return false
24
+}
25
+
26
+// ListFiles returns all files in FS
27
+func (fs *BundledFS) ListFiles() (files []macaron.TemplateFile) {
28
+	for _, filename := range fs.List() {
29
+		files = append(files, &BundledFile{fs: fs, FileName: filename})
30
+	}
31
+	return files
32
+}
33
+
34
+// Get returns the content of filename
35
+func (fs *BundledFS) Get(filename string) (io.Reader, error) {
36
+	return bytes.NewReader(fs.Bytes(filename)), nil
37
+}
38
+
39
+// BundledFile represents a file in a BundledFS
40
+type BundledFile struct {
41
+	fs       *BundledFS
42
+	FileName string
43
+}
44
+
45
+// Name represents the name of the file
46
+func (b *BundledFile) Name() string {
47
+	return strings.TrimSuffix(b.FileName, path.Ext(b.FileName))
48
+}
49
+
50
+// Data returns the content of file
51
+func (b *BundledFile) Data() []byte {
52
+	return b.fs.Bytes(b.FileName)
53
+}
54
+
55
+// Ext returns the file extension
56
+func (b *BundledFile) Ext() string {
57
+	return path.Ext(b.FileName)
58
+}

+ 62
- 0
web/migration/repos.go View File

@@ -0,0 +1,62 @@
1
+package migration
2
+
3
+import (
4
+	bgctx "context"
5
+	"regexp"
6
+	"strings"
7
+
8
+	"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/migrations"
9
+	"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/web/context"
10
+	"github.com/google/go-github/github"
11
+)
12
+
13
+const repoRegex = "^[A-Za-z0-9-.]+/[A-Za-z0-9-.]+$"
14
+
15
+// ListRepos shows all available repos of the signed in user
16
+func ListRepos(ctx *context.Context) {
17
+	repos, _, err := ctx.Client.Repositories.List(bgctx.Background(), ctx.User.Username, &github.RepositoryListOptions{
18
+		ListOptions: github.ListOptions{
19
+			PerPage: 100,
20
+		},
21
+	})
22
+
23
+	if err != nil {
24
+		ctx.Handle(500, "list repositories", err)
25
+		return
26
+	}
27
+	ctx.Data["Repos"] = repos
28
+	ctx.HTML(200, "repos")
29
+}
30
+
31
+// ListReposPost handles the form submission of ListRepos
32
+func ListReposPost(ctx *context.Context) {
33
+	if err := ctx.Req.ParseForm(); err != nil {
34
+		ctx.Handle(500, "parse form", err)
35
+		return
36
+	}
37
+	// TODO implement migration options
38
+	job := migrations.NewJob(&migrations.Options{
39
+		Labels:       true,
40
+		Comments:     true,
41
+		Issues:       true,
42
+		Milestones:   true,
43
+		PullRequests: true,
44
+		Strategy:     migrations.Classic,
45
+		NewOwnerID:   int(ctx.GiteaUser.ID), // TODO implement user/org selection
46
+	}, ctx.GiteaClient, ctx.Client)
47
+	for repo, val := range ctx.Req.Form {
48
+		activated := strings.Join(val, "")
49
+		if activated != "on" {
50
+			continue
51
+		}
52
+		// Validate repo format (reponame/owner)
53
+		if matched, err := regexp.MatchString(repoRegex, repo); err != nil || !matched {
54
+			continue
55
+		}
56
+		job.Repositories = append(job.Repositories, repo)
57
+	}
58
+	go job.StartMigration()
59
+	ctx.SetCurrentJob(job)
60
+	ctx.Data["StatusReport"] = job.StatusReport()
61
+	ctx.HTML(200, "migration")
62
+}

+ 14
- 0
web/migration/status.go View File

@@ -0,0 +1,14 @@
1
+package migration
2
+
3
+import (
4
+	"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/web/context"
5
+)
6
+
7
+// StatusReport returns the status of the current Job in JSON
8
+func StatusReport(ctx *context.Context) {
9
+	if job := ctx.GetCurrentJob(); job != nil {
10
+		ctx.JSON(200, job.StatusReport())
11
+		return
12
+	}
13
+	ctx.Status(404)
14
+}

+ 364
- 0
web/public/css/semantic.min.css
File diff suppressed because it is too large
View File


+ 4
- 0
web/public/js/jquery-3.1.1.min.js
File diff suppressed because it is too large
View File


+ 121
- 0
web/public/js/repos-status.js View File

@@ -0,0 +1,121 @@
1
+var done = false;
2
+
3
+function update() {
4
+    $.getJSON("/status", function(data) {
5
+        handleData(data);
6
+    }).always(function() {
7
+        if(!done){
8
+            window.setTimeout(update, 1000);
9
+        }
10
+    });
11
+}
12
+$(function() {
13
+   $(".repo-progress").progress({
14
+       text: {
15
+           active  : 'Migrated or failed {value} of {total} repositories',
16
+           success : '{total} repositories migrated or failed!'
17
+       },
18
+       total: $(".repo-progress").data("total"),
19
+       value: 0
20
+   });
21
+});
22
+
23
+function handleData(data) {
24
+    if(Object.keys(data.finished).length + Object.keys(data.failed).length === $(".repo-progress").progress('get total')) {
25
+        $(".repo-progress").progress('complete');
26
+        done = true;
27
+    } else {
28
+        $(".repo-progress").progress('set progress', Object.keys(data.finished).length + Object.keys(data.failed).length);
29
+    }
30
+    data.pending.forEach(function(repo) {
31
+        var content = contentFromRepo(repo);
32
+        if (!content.hasClass("pending")) {
33
+            content.html(renderPending().html());
34
+            content.addClass("pending");
35
+        }
36
+    });
37
+    forEach(data.failed, function (repo, errormsg) {
38
+        var content = contentFromRepo(repo);
39
+        if (!content.hasClass("failed")) {
40
+            content.html(renderFailed(errormsg).html());
41
+            content.addClass("failed");
42
+        }
43
+    });
44
+    forEach(data.running, function (repo, report) {
45
+        var content = handleNonPending(repo, report);
46
+        if (content.find(".comment-progress").progress('get total') !== report.total_comments) {
47
+            content.find(".comment-progress").progress('set total', report.total_comments)
48
+        }
49
+        if (content.find(".issue-progress").progress('get total') !== report.total_issues) {
50
+            content.find(".issue-progress").progress('set total', report.total_issues)
51
+        }
52
+        content.find(".comment-progress").progress('set progress', report.migrated_comments + report.failed_comments);
53
+        content.find(".issue-progress").progress('set progress', report.migrated_issues + report.failed_issues);
54
+    });
55
+    forEach(data.finished, function (repo, report) {
56
+        var content = handleNonPending(repo, report);
57
+        if (content.find(".comment-progress").progress('get total') !== report.total_comments) {
58
+            content.find(".comment-progress").progress('set total', report.total_comments)
59
+        }
60
+        if (content.find(".issue-progress").progress('get total') !== report.total_issues) {
61
+            content.find(".issue-progress").progress('set total', report.total_issues)
62
+        }
63
+        content.find(".comment-progress").progress('set progress', report.migrated_comments + report.failed_comments);
64
+        content.find(".issue-progress").progress('set progress', report.migrated_issues + report.failed_issues);
65
+        content.find(".issue-progress").progress('complete');
66
+        content.find(".comment-progress").progress('complete');
67
+    });
68
+}
69
+function forEach(object, callback) {
70
+    for(var prop in object) {
71
+        if(object.hasOwnProperty(prop)) {
72
+            callback(prop, object[prop]);
73
+        }
74
+    }
75
+}
76
+
77
+function handleNonPending(repo, report) {
78
+    var content = contentFromRepo(repo);
79
+    if(!content.hasClass("non-pending")) {
80
+        content.html(renderNonPending().html());
81
+        content.find(".issue-progress").progress({
82
+            text: {
83
+                active  : 'Migrated {value} of {total} issues',
84
+                success : '{total} issues migrated!'
85
+            },
86
+            total: report.total_issues,
87
+            value: report.migrated_issues + report.failed_issues,
88
+        });
89
+        content.find(".comment-progress").progress({
90
+            text: {
91
+                active  : 'Migrated {value} of {total} comments',
92
+                success : '{total} comments migrated!'
93
+            },
94
+            total: report.total_comments+1,
95
+            value: report.migrated_comments + report.failed_comments,
96
+        });
97
+        content.addClass("non-pending");
98
+    }
99
+    content.find(".failed-issues").text(report.failed_issues);
100
+    content.find(".failed-comments").text(report.failed_comments);
101
+    return content
102
+}
103
+
104
+function contentFromRepo(repo) {
105
+    return $(".repo-content[data-repo='" + repo + "']")
106
+}
107
+
108
+function renderPending() {
109
+    return $("#content-pending").clone();
110
+}
111
+
112
+function renderFailed(errormsg) {
113
+    var failed = $("#content-failed").clone();
114
+    failed.find(".errormsg").text(errormsg);
115
+    return failed
116
+}
117
+function renderNonPending() {
118
+    return $("#content-nonpending").clone();
119
+}
120
+
121
+$(update());

+ 30
- 0
web/public/js/select-repos.js View File

@@ -0,0 +1,30 @@
1
+var repo_regex = /^[A-Za-z0-9-.]+\/[A-Za-z0-9-.]+$/;
2
+
3
+function openSelectRepoModal() {
4
+   $("#add-repos").modal('setting', {
5
+       onApprove: function () {
6
+           var repos = parseReposInTextArea();
7
+           for (var idx in repos) {
8
+               var repo = repos[idx];
9
+               if (repo_regex.test(repo)){
10
+                   addRepoToList(repo);
11
+               }else {
12
+                   alert(repo + " is not a repository")
13
+               }
14
+           }
15
+           return true;
16
+       }
17
+   }).modal('show');
18
+}
19
+
20
+function parseReposInTextArea() {
21
+    var text = $("#repo-textform").val();
22
+    return text.split("\n");
23
+}
24
+
25
+function addRepoToList(repo) {
26
+    var item = $("#repo-item").children('.item').clone();
27
+    item.html(item.html().replace(/FULL_REPO_NAME/g, repo));
28
+    console.log(repo, item.html());
29
+    $("#repo-list").append(item);
30
+}

+ 11
- 0
web/public/js/semantic.min.js
File diff suppressed because it is too large
View File


+ 68
- 0
web/router.go View File

@@ -0,0 +1,68 @@
1
+package web
2
+
3
+import (
4
+	"encoding/gob"
5
+
6
+	"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/web/auth"
7
+	"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/web/context"
8
+	"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/web/migration"
9
+	"github.com/go-macaron/binding"
10
+	"github.com/go-macaron/session"
11
+	"github.com/gobuffalo/packr"
12
+	"gopkg.in/macaron.v1"
13
+)
14
+
15
+// InitRoutes initiates the gin routes and loads values from config
16
+func InitRoutes() *macaron.Macaron {
17
+	gob.Register(&context.User{})
18
+	m := macaron.Classic()
19
+	auth.InitGitHubOAuthConfig()
20
+	tmplBox := packr.NewBox("templates")
21
+	publicBox := packr.NewBox("public")
22
+	m.Use(macaron.Recovery())
23
+	m.Use(session.Sessioner(session.Options{
24
+		Provider:       "file",
25
+		ProviderConfig: "data/sessions",
26
+	}))
27
+	m.Use(macaron.Renderer(macaron.RenderOptions{
28
+		TemplateFileSystem: &BundledFS{tmplBox},
29
+	}))
30
+	m.Use(macaron.Statics(macaron.StaticOptions{
31
+		Prefix:     "static",
32
+		FileSystem: publicBox,
33
+	}, ""))
34
+	m.Use(context.Contexter())
35
+
36
+	// BEGIN: Router
37
+	m.Get("/", func(ctx *context.Context) {
38
+		if ctx.User != nil {
39
+			if ctx.GiteaUser == nil {
40
+				ctx.HTML(200, "login_gitea")
41
+				return
42
+			}
43
+			ctx.HTML(200, "dashboard")
44
+			return
45
+		}
46
+		ctx.HTML(200, "login_github") // 200 is the response code.
47
+	})
48
+	m.Get("/logout", func(c *macaron.Context, sess session.Store) {
49
+		sess.Destory(c)
50
+		c.Redirect("/")
51
+	})
52
+	m.Group("/github", func() {
53
+		m.Get("/", auth.RedirectToGitHub)
54
+		m.Get("/callback", auth.CallbackFromGitHub)
55
+	})
56
+	m.Group("/gitea", func() {
57
+		m.Post("/", binding.BindIgnErr(auth.GiteaLoginForm{}), auth.LoginToGitea)
58
+	})
59
+	m.Combo("/repos", reqSignIn).Get(migration.ListRepos).Post(migration.ListReposPost)
60
+	m.Get("/status", reqSignIn, migration.StatusReport)
61
+	return m
62
+}
63
+
64
+func reqSignIn(ctx *context.Context) {
65
+	if ctx.User == nil || ctx.GiteaUser == nil {
66
+		ctx.Redirect("/")
67
+	}
68
+}

+ 2
- 0
web/templates/base/footer.tmpl View File

@@ -0,0 +1,2 @@
1
+</body>
2
+</html>

+ 34
- 0
web/templates/base/head.tmpl View File

@@ -0,0 +1,34 @@
1
+<!DOCTYPE html>
2
+<html>
3
+<head>
4
+    <!-- Standard Meta -->
5
+    <meta charset="utf-8" />
6
+    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
7
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
8
+
9
+    <!-- Site Properties -->
10
+    <title>Gitea Migrator</title>
11
+
12
+    <script src="/static/js/jquery-3.1.1.min.js"></script>
13
+    <script src="/static/js/semantic.min.js"></script>
14
+    <link href="/static/css/semantic.min.css" rel="stylesheet">
15
+    <style type="text/css">
16
+        body {
17
+            background-color: #DADADA;
18
+            padding: 30px;
19
+        }
20
+        body > .grid {
21
+            height: 100%;
22
+        }
23
+        .column {
24
+            max-width: 600px;
25
+        }
26
+        .big {
27
+            max-width: 1000px !important;
28
+        }
29
+    </style>
30
+</head>
31
+<body>
32
+{{if .Flash.ErrorMsg}}
33
+    <div class="ui error message">{{.Flash.ErrorMsg}}</div>
34
+{{end}}

+ 32
- 0
web/templates/dashboard.tmpl View File

@@ -0,0 +1,32 @@
1
+{{template "base/head" .}}
2
+<div class="ui middle aligned center aligned grid">
3
+    <div class="column">
4
+        <h1 class="ui image header">
5
+            <div class="content">
6
+                Gitea Migrator
7
+            </div>
8
+        </h1>
9
+        <div class="ui message">
10
+            You've connected your GitHub and Gitea account.
11
+        </div>
12
+        <div class="ui icon message">
13
+            <i class="icon github"></i>
14
+            <div class="content">
15
+                <h3 class="header">GitHub connected</h3>
16
+                You're logged in as {{template "modules/username" .User}}. <a href="/logout">Not you?</a>
17
+            </div>
18
+        </div>
19
+        <div class="ui icon attached message">
20
+            <i class="icon lock"></i>
21
+            <div class="content">
22
+                <h3 class="header">Gitea connected</h3>
23
+                You're logged in as {{template "modules/username" .GiteaUser}}. <a href="/logout">Not you?</a>
24
+            </div>
25
+        </div>
26
+
27
+        <div class="ui stacked segment">
28
+            <a href="/repos" class="ui fluid large green labeld icon button"><i class="icon list"></i> Migrate Repositories...</a>
29
+        </div>
30
+    </div>
31
+</div>
32
+{{template "base/footer" .}}

+ 67
- 0
web/templates/login_gitea.tmpl View File

@@ -0,0 +1,67 @@
1
+{{template "base/head" .}}
2
+<div class="ui middle aligned center aligned grid">
3
+    <div class="column">
4
+        <h1 class="ui image header">
5
+            <div class="content">
6
+                Gitea Migrator
7
+            </div>
8
+        </h1>
9
+        <div class="ui message">
10
+            You've connected you GitHub account. The next step is to connect to your Gitea instance.
11
+        </div>
12
+        <div class="ui icon message">
13
+            <i class="icon github"></i>
14
+            <div class="content">
15
+                <h3 class="header">GitHub connected</h3>
16
+                You're logged in as {{template "modules/username" .User}}. <a href="/logout">Not you?</a>
17
+            </div>
18
+        </div>
19
+        <div class="ui icon attached message">
20
+            <i class="icon lock"></i>
21
+            <div class="content">
22
+                <h3 class="header">Login to Gitea</h3>
23
+                You can use your user credentials or an access token to log in to your Gitea instance. If you use your credentials
24
+                an access token will be created for you.
25
+            </div>
26
+        </div>
27
+        <form action="/gitea" method="POST" class="ui large form">
28
+            <div class="ui attached fluid segment">
29
+                <div class="field">
30
+                    <label>Gitea URL</label>
31
+                    <input name="gitea-url" type="url">
32
+                </div>
33
+                <h3>Credentials</h3>
34
+                <div class="ui top attached tabular menu">
35
+                    <a class="active item" data-tab="password">Username + Password</a>
36
+                    <a class="item" data-tab="access-token">Access Token</a>
37
+                </div>
38
+                <div class="ui bottom attached tab segment active" data-tab="password">
39
+                    <div class="field">
40
+                        <label>Username</label>
41
+                        <input name="username" placeholder="Username" type="text">
42
+                    </div>
43
+                    <div class="field">
44
+                        <label>Password</label>
45
+                        <input name="password" type="password">
46
+                    </div>
47
+                    <button type="submit" name="use" value="password" class="ui fluid large green submit button">Login to Gitea</button>
48
+                </div>
49
+                <div class="ui bottom attached tab segment" data-tab="access-token">
50
+                    <div class="field">
51
+                        <label>Access Token</label>
52
+                        <input name="access-token" type="password">
53
+                    </div>
54
+                    <button type="submit" name="use" value="token" class="ui fluid large green submit button">Login to Gitea</button>
55
+                </div>
56
+            </div>
57
+
58
+            <div class="ui error message"></div>
59
+        </form>
60
+    </div>
61
+</div>
62
+<script>
63
+    $('.menu .item')
64
+            .tab()
65
+    ;
66
+</script>
67
+{{template "base/footer" .}}

+ 22
- 0
web/templates/login_github.tmpl View File

@@ -0,0 +1,22 @@
1
+{{template "base/head" .}}
2
+<div class="ui middle aligned center aligned grid">
3
+    <div class="column">
4
+        <h1 class="ui image header">
5
+            <div class="content">
6
+                Gitea Migrator
7
+            </div>
8
+        </h1>
9
+        <div class="ui message">
10
+            Migrate all your GitHub repositories to your Gitea instance including all issues, labels and milestones.
11
+        </div>
12
+        <form class="ui large form">
13
+            <div class="ui stacked segment">
14
+                <a href="/github" class="ui fluid large green submit labeled icon button"><i class="icon github"></i> Login with GitHub</a>
15
+            </div>
16
+
17
+            <div class="ui error message"></div>
18
+
19
+        </form>
20
+    </div>
21
+</div>
22
+{{template "base/footer" .}}

+ 54
- 0
web/templates/migration.tmpl View File

@@ -0,0 +1,54 @@
1
+{{template "base/head" .}}
2
+<script src="static/js/repos-status.js"></script>
3
+<div class="ui middle aligned center aligned grid">
4
+    <div class="column big">
5
+        <h1 class="ui image header">
6
+            <div class="content">
7
+                Migrating Repositories...
8
+            </div>
9
+        </h1>
10
+        <div class="ui message">
11
+            Your repositories get migrated at the moment. This page refreshs automatically.
12
+        </div>
13
+        <div class="repo-progress ui progress" data-total="{{len .StatusReport.Pending}}">
14
+            <div class="bar"></div>
15
+            <div class="label">Migrating repositories</div>
16
+        </div>
17
+        <div class="ui three stackable cards" id="migration-list">
18
+        {{range .StatusReport.Pending}}
19
+            <div class="ui repo-card card" data-repo="{{.}}" data-status="pending">
20
+                <div class="content">
21
+                    <div class="header">
22
+                        <i class="icon github"></i>{{.}}</div>
23
+                </div>
24
+                <div class="repo-content content" data-repo="{{.}}">
25
+                    <div class="ui active centered inline small text loader">Pending...</div>
26
+                </div>
27
+            </div>
28
+        {{end}}
29
+        </div>
30
+    </div>
31
+</div>
32
+<div id="content-pending" style="display: none;">
33
+    <div class="ui active centered inline small text loader">Pending...</div>
34
+</div>
35
+<div id="content-nonpending" style="display: none;">
36
+    <div class="issue-progress ui indicating progress">
37
+        <div class="bar"></div>
38
+        <div class="label">Migrating issues</div>
39
+    </div>
40
+    <p><b class="failed-issues">0</b> migration(s) of issues failed</p>
41
+    <div class="comment-progress ui indicating progress">
42
+        <div class="bar"></div>
43
+        <div class="label">Migrating comments</div>
44
+    </div>
45
+    <p><b class="failed-comments">0</b> migration(s) of comments failed</p>
46
+</div>
47
+<div id="content-failed" style="display: none;">
48
+    <div class="ui negative message">
49
+        <div class="header">
50
+            Error while migrating
51
+        </div>
52
+        <p class="errormsg"></p>
53
+    </div>
54
+</div>

+ 1
- 0
web/templates/modules/username.tmpl View File

@@ -0,0 +1 @@
1
+<code>{{.Username}}</code>

+ 77
- 0
web/templates/repos.tmpl View File

@@ -0,0 +1,77 @@
1
+{{template "base/head" .}}
2
+<script src="static/js/select-repos.js"></script>
3
+<div class="ui middle aligned center aligned grid">
4
+    <div class="column">
5
+        <h1 class="ui image header">
6
+            <div class="content">
7
+                Migrate Repositories
8
+            </div>
9
+        </h1>
10
+        <div class="ui message">
11
+            Select the repositories you'd like to migrate.
12
+        </div>
13
+        <div class="ui stacked segment">
14
+            <form action="/repos" method="POST">
15
+                <div class="ui horizontal link list">
16
+                    <a class="item" onclick="$('.repo-toggle').prop('checked', true);">
17
+                        Select all
18
+                    </a>
19
+                    <a class="item" onclick="$('.repo-toggle').prop('checked', false);">
20
+                        Deselect all
21
+                    </a>
22
+                </div>
23
+                <div class="ui relaxed divided list" id="repo-list">
24
+                {{range .Repos}}
25
+                    <div class="item">
26
+                        <i class="large github middle aligned icon"></i>
27
+                        <div class="content">
28
+                            <div class="ui left toggle checkbox">
29
+                                <input checked id="{{.GetFullName}}" class="repo-toggle" name="{{.GetFullName}}" type="checkbox">
30
+                                <label for="{{.GetFullName}}"><a class="header">{{.GetFullName}}</a></label>
31
+                            </div>
32
+                        </div>
33
+                    </div>
34
+                {{end}}
35
+                </div>
36
+                <button type="button" onclick="openSelectRepoModal();" id="open-other-btn" class="ui fluid large labeled icon button"><i class="icon add"></i> Add other repositories...</button>
37
+                <div class="ui divider"></div>
38
+                <button type="submit" class="ui fluid large green labeled icon button"><i class="icon exchange"></i> Migrate selected repositories...</button>
39
+            </form>
40
+
41
+        </div>
42
+    </div>
43
+</div>
44
+<div id="repo-item" style="display: none !important;">
45
+    <div class="item">
46
+        <i class="large github middle aligned icon"></i>
47
+        <div class="content">
48
+            <div class="ui left toggle checkbox">
49
+                <input checked id="FULL_REPO_NAME" name="FULL_REPO_NAME" type="checkbox">
50
+                <label for="FULL_REPO_NAME"><a class="header">FULL_REPO_NAME</a></label>
51
+            </div>
52
+        </div>
53
+    </div>
54
+</div>
55
+<div class="ui modal" id="add-repos">
56
+    <div class="header">Add other repositories...</div>
57
+    <div class="content">
58
+        <div class="ui small icon message"><i class="icon code"></i>
59
+            <div class="content">
60
+                Please add all repositories you'd like to add in the box below. Write
61
+                each repository in a separate line and split the repository owner and name with a "/".
62
+            </div>
63
+        </div>
64
+        <div class="ui form">
65
+            <div class="field">
66
+                <label>Line seperated list of repositories</label>
67
+                <textarea id="repo-textform" placeholder="go-gitea/gitea&#10;go-gitea/git"></textarea>
68
+            </div>
69
+        </div>
70
+    </div>
71
+
72
+    <div class="actions">
73
+        <div id="add-repos-btn" class="ui approve green button">Add</div>
74
+        <div class="ui cancel button">Cancel</div>
75
+    </div>
76
+</div>
77
+{{template "base/footer" .}}

+ 14
- 0
web/templates/status/403.tmpl View File

@@ -0,0 +1,14 @@
1
+{{template "base/head" .}}
2
+<div class="ui middle aligned center aligned grid">
3
+    <div class="column">
4
+        <h1 class="ui image header">
5
+            <div class="content">
6
+                Access denied
7
+            </div>
8
+        </h1>
9
+        <div class="ui error message">
10
+            {{.ErrTitle}}
11
+        </div>
12
+    </div>
13
+</div>
14
+{{template "base/footer" .}}

+ 14
- 0
web/templates/status/404.tmpl View File

@@ -0,0 +1,14 @@
1
+{{template "base/head" .}}
2
+<div class="ui middle aligned center aligned grid">
3
+    <div class="column">
4
+        <h1 class="ui image header">
5