Web interface implementation (#18)
parent
afd81459b9
commit
2872647d3d
@ -0,0 +1,25 @@ |
||||
#Build stage |
||||
FROM golang:1.10-alpine3.7 AS build-env |
||||
|
||||
ARG VERSION |
||||
|
||||
#Build deps |
||||
RUN apk --no-cache add build-base git ca-certificates |
||||
|
||||
#Setup repo |
||||
COPY . ${GOPATH}/src/git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator |
||||
WORKDIR ${GOPATH}/src/git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator |
||||
|
||||
RUN make clean docker-binary-web |
||||
|
||||
FROM alpine:3.7 |
||||
LABEL maintainer="info@jonasfranz.software" |
||||
|
||||
COPY --from=build-env /go/src/git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/gitea-github-migrator /usr/local/bin/gitea-github-migrator |
||||
|
||||
VOLUME "/data" |
||||
VOLUME "/usr/local/bin/data" |
||||
|
||||
COPY --from=build-env /go/src/git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/config.example.yml /data/config.example.yml |
||||
|
||||
ENTRYPOINT ["/usr/local/bin/gitea-github-migrator", "web", "-c", "/data/config.yml"] |
@ -1,5 +1,5 @@ |
||||
MIT License |
||||
Copyright (c) <year> <copyright holders> |
||||
Copyright (c) 2018 Jonas Franz |
||||
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: |
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. |
||||
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. |
||||
|
@ -1,23 +0,0 @@ |
||||
package cmd |
||||
|
||||
import ( |
||||
"context" |
||||
"testing" |
||||
|
||||
"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/migrations" |
||||
"github.com/google/go-github/github" |
||||
"github.com/stretchr/testify/assert" |
||||
) |
||||
|
||||
// Test_migrate is an integration tests for migrate command
|
||||
// using repo JonasFranzDEV/test
|
||||
func Test_migrate(t *testing.T) { |
||||
assert.NoError(t, migrate( |
||||
context.Background(), |
||||
github.NewClient(nil), |
||||
migrations.DemoMigratory, |
||||
"JonasFranzDEV", |
||||
"test", |
||||
false, |
||||
)) |
||||
} |
@ -0,0 +1,48 @@ |
||||
// +build web
|
||||
|
||||
package cmd |
||||
|
||||
import ( |
||||
"fmt" |
||||
"net/http" |
||||
|
||||
"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/config" |
||||
"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/web" |
||||
"github.com/jinzhu/configor" |
||||
"github.com/sirupsen/logrus" |
||||
"github.com/urfave/cli" |
||||
) |
||||
|
||||
// CmdWeb stars the web interface
|
||||
var CmdWeb = cli.Command{ |
||||
Name: "web", |
||||
Usage: "Starts the web interface", |
||||
Action: runWeb, |
||||
Flags: []cli.Flag{ |
||||
cli.StringFlag{ |
||||
Name: "c,config", |
||||
Usage: "config file", |
||||
Value: "config.yml", |
||||
EnvVar: "MIGRATOR_CONFIG", |
||||
}, |
||||
}, |
||||
} |
||||
|
||||
func runWeb(ctx *cli.Context) error { |
||||
if err := configor.New(&configor.Config{ErrorOnUnmatchedKeys: true}).Load(&config.Config, ctx.String("config")); err != nil { |
||||
return err |
||||
} |
||||
r := web.InitRoutes() |
||||
|
||||
hostname := config.Config.Web.Host |
||||
if len(hostname) == 0 { |
||||
hostname = "0.0.0.0" |
||||
} |
||||
port := config.Config.Web.Port |
||||
if port == 0 { |
||||
port = 4000 |
||||
} |
||||
logrus.Infof("Server is running at http://%s:%d", hostname, port) |
||||
logrus.SetLevel(logrus.PanicLevel) |
||||
return http.ListenAndServe(fmt.Sprintf("%s:%d", hostname, port), r) |
||||
} |
@ -0,0 +1,13 @@ |
||||
// +build !web
|
||||
|
||||
package main |
||||
|
||||
import ( |
||||
"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/cmd" |
||||
"github.com/urfave/cli" |
||||
) |
||||
|
||||
var cmds = cli.Commands{ |
||||
cmd.CmdMigrate, |
||||
cmd.CmdMigrateAll, |
||||
} |
@ -0,0 +1,14 @@ |
||||
// +build web
|
||||
|
||||
package main |
||||
|
||||
import ( |
||||
"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/cmd" |
||||
"github.com/urfave/cli" |
||||
) |
||||
|
||||
var cmds = cli.Commands{ |
||||
cmd.CmdMigrate, |
||||
cmd.CmdMigrateAll, |
||||
cmd.CmdWeb, |
||||
} |
@ -0,0 +1,8 @@ |
||||
# GitHub contains the OAuth2 application data obtainable from GitHub |
||||
GitHub: |
||||
client_id: GITHUB_OAUTH_CLIENT_ID |
||||
client_secret: GITHUB_OAUTH_SECRET |
||||
# Web contains the configuration for the integrated web server |
||||
Web: |
||||
port: 4000 |
||||
host: 0.0.0.0 |
@ -0,0 +1,13 @@ |
||||
package config |
||||
|
||||
// Config holds all configurations needed for web interface
|
||||
var Config = struct { |
||||
GitHub struct { |
||||
ClientID string `required:"true" yaml:"client_id"` |
||||
ClientSecret string `required:"true" yaml:"client_secret"` |
||||
} |
||||
Web struct { |
||||
Host string `yaml:"host"` |
||||
Port int `yaml:"port"` |
||||
} |
||||
}{} |
@ -0,0 +1,264 @@ |
||||
package migrations |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
"regexp" |
||||
"strconv" |
||||
"strings" |
||||
"sync" |
||||
|
||||
"code.gitea.io/sdk/gitea" |
||||
"github.com/google/go-github/github" |
||||
"github.com/sirupsen/logrus" |
||||
) |
||||
|
||||
// FetchMigratory adds GitHub fetching functions to migratory
|
||||
type FetchMigratory struct { |
||||
Migratory |
||||
GHClient *github.Client |
||||
RepoOwner string |
||||
RepoName string |
||||
} |
||||
|
||||
func (fm *FetchMigratory) ctx() context.Context { |
||||
return context.Background() |
||||
} |
||||
|
||||
// MigrateFromGitHub migrates RepoOwner/RepoName from GitHub to Gitea
|
||||
func (fm *FetchMigratory) MigrateFromGitHub() error { |
||||
fm.Status = &MigratoryStatus{ |
||||
Stage: Importing, |
||||
} |
||||
logrus.WithFields(logrus.Fields{ |
||||
"repo": fmt.Sprintf("%s/%s", fm.RepoOwner, fm.RepoName), |
||||
}).Info("migrating git repository") |
||||
ghRepo, _, err := fm.GHClient.Repositories.Get(fm.ctx(), fm.RepoOwner, fm.RepoName) |
||||
if err != nil { |
||||
fm.Status.Stage = Failed |
||||
fm.Status.FatalError = err |
||||
return fmt.Errorf("GHClient Repostiories Get: %v", err) |
||||
} |
||||
fm.repository, err = fm.Repository(ghRepo) |
||||
if err != nil { |
||||
fm.Status.Stage = Failed |
||||
fm.Status.FatalError = err |
||||
return fmt.Errorf("Repository migration: %v", err) |
||||
} |
||||
logrus.WithFields(logrus.Fields{ |
||||
"repo": fmt.Sprintf("%s/%s", fm.RepoOwner, fm.RepoName), |
||||
}).Info("git repository migrated") |
||||
if fm.Options.Issues || fm.Options.PullRequests { |
||||
var commentsChan chan *[]*github.IssueComment |
||||
if fm.Options.Comments { |
||||
commentsChan = fm.fetchCommentsAsync() |
||||
} |
||||
issues, err := fm.FetchIssues() |
||||
if err != nil { |
||||
fm.Status.Stage = Failed |
||||
fm.Status.FatalError = err |
||||
logrus.WithFields(logrus.Fields{ |
||||
"repo": fmt.Sprintf("%s/%s", fm.RepoOwner, fm.RepoName), |
||||
}).Fatalf("migration failed: %v", fm.Status.FatalError) |
||||
return err |
||||
} |
||||
fm.Status.Stage = Migrating |
||||
fm.Status.Issues = int64(len(issues)) |
||||
migratedIssues := make(map[int]*gitea.Issue) |
||||
for _, issue := range issues { |
||||
if (!issue.IsPullRequest() || fm.Options.PullRequests) && |
||||
(issue.IsPullRequest() || fm.Options.Issues) { |
||||
migratedIssues[issue.GetNumber()], err = fm.Issue(issue) |
||||
if err != nil { |
||||
fm.Status.IssuesError++ |
||||
logrus.WithFields(logrus.Fields{ |
||||
"repo": fmt.Sprintf("%s/%s", fm.RepoOwner, fm.RepoName), |
||||
"issue": issue.GetNumber(), |
||||
}).Warnf("error while migrating: %v", err) |
||||
continue |
||||
} |
||||
fm.Status.IssuesMigrated++ |
||||
logrus.WithFields(logrus.Fields{ |
||||
"repo": fmt.Sprintf("%s/%s", fm.RepoOwner, fm.RepoName), |
||||
"issue": issue.GetNumber(), |
||||
}).Info("issue migrated") |
||||
} else { |
||||
fm.Status.Issues-- |
||||
} |
||||
} |
||||
if fm.Options.Comments { |
||||
var comments []*github.IssueComment |
||||
if cmts := <-commentsChan; cmts == nil { |
||||
fm.Status.Stage = Failed |
||||
err := fmt.Errorf("error while fetching issue comments") |
||||
logrus.WithFields(logrus.Fields{ |
||||
"repo": fmt.Sprintf("%s/%s", fm.RepoOwner, fm.RepoName), |
||||
}).Fatalf("migration failed: %v", fm.Status.FatalError) |
||||
return err |
||||
} else { |
||||
comments = *cmts |
||||
} |
||||
|
||||
if err != nil { |
||||
fm.Status.Stage = Failed |
||||
fm.Status.FatalError = err |
||||
logrus.WithFields(logrus.Fields{ |
||||
"repo": fmt.Sprintf("%s/%s", fm.RepoOwner, fm.RepoName), |
||||
}).Fatalf("migration failed: %v", fm.Status.FatalError) |
||||
return err |
||||
} |
||||
fm.Status.Comments = int64(len(comments)) |
||||
commentsByIssue := make(map[*gitea.Issue][]*github.IssueComment, len(migratedIssues)) |
||||
for _, comment := range comments { |
||||
issueIndex, err := getIssueIndexFromHTMLURL(comment.GetHTMLURL()) |
||||
if err != nil { |
||||
fm.Status.CommentsError++ |
||||
logrus.WithFields(logrus.Fields{ |
||||
"repo": fmt.Sprintf("%s/%s", fm.RepoOwner, fm.RepoName), |
||||
"issue": issueIndex, |
||||
"comment": comment.GetID(), |
||||
}).Warnf("error while migrating comment: %v", err) |
||||
continue |
||||
} |
||||
if issue, ok := migratedIssues[issueIndex]; ok && issue != nil { |
||||
if list, ok := commentsByIssue[issue]; !ok && list != nil { |
||||
commentsByIssue[issue] = []*github.IssueComment{comment} |
||||
} else { |
||||
commentsByIssue[issue] = append(list, comment) |
||||
} |
||||
} else { |
||||
fm.Status.CommentsError++ |
||||
continue |
||||
} |
||||
} |
||||
wg := sync.WaitGroup{} |
||||
for issue, comms := range commentsByIssue { |
||||
wg.Add(1) |
||||
go func(i *gitea.Issue, cs []*github.IssueComment) { |
||||
for _, comm := range cs { |
||||
if _, err := fm.IssueComment(i, comm); err != nil { |
||||
fm.Status.CommentsError++ |
||||
logrus.WithFields(logrus.Fields{ |
||||
"repo": fmt.Sprintf("%s/%s", fm.RepoOwner, fm.RepoName), |
||||
"comment": comm.GetID(), |
||||
}).Warnf("error while migrating comment: %v", err) |
||||
continue |
||||
} |
||||
fm.Status.CommentsMigrated++ |
||||
logrus.WithFields(logrus.Fields{ |
||||
"repo": fmt.Sprintf("%s/%s", fm.RepoOwner, fm.RepoName), |
||||
"comment": comm.GetID(), |
||||
}).Info("comment migrated") |
||||
} |
||||
wg.Done() |
||||
}(issue, comms) |
||||
} |
||||
wg.Wait() |
||||
} |
||||
} |
||||
if fm.Status.FatalError != nil { |
||||
fm.Status.Stage = Failed |
||||
logrus.WithFields(logrus.Fields{ |
||||
"repo": fmt.Sprintf("%s/%s", fm.RepoOwner, fm.RepoName), |
||||
}).Fatalf("migration failed: %v", fm.Status.FatalError) |
||||
return nil |
||||
} |
||||
fm.Status.Stage = Finished |
||||
logrus.WithFields(logrus.Fields{ |
||||
"repo": fmt.Sprintf("%s/%s", fm.RepoOwner, fm.RepoName), |
||||
}).Info("migration successful") |
||||
return nil |
||||
} |
||||
|
||||
var issueIndexRegex = regexp.MustCompile(`/(issues|pull)/([0-9]+)#`) |
||||
|
||||
func getIssueIndexFromHTMLURL(htmlURL string) (int, error) { |
||||
// Alt is 4 times faster but more error prune
|
||||
if res, err := getIssueIndexFromHTMLURLAlt(htmlURL); err == nil { |
||||
return res, nil |
||||
} |
||||
matches := issueIndexRegex.FindStringSubmatch(htmlURL) |
||||
if len(matches) < 3 { |
||||
return 0, fmt.Errorf("cannot parse issue id from HTML URL: %s", htmlURL) |
||||
} |
||||
return strconv.Atoi(matches[2]) |
||||
} |
||||
func getIssueIndexFromHTMLURLAlt(htmlURL string) (int, error) { |
||||
res := strings.Split(htmlURL, "/issues/") |
||||
if len(res) != 2 { |
||||
res = strings.Split(htmlURL, "/pull/") |
||||
} |
||||
if len(res) != 2 { |
||||
return 0, fmt.Errorf("invalid HTMLURL: %s", htmlURL) |
||||
} |
||||
number := res[1] |
||||
number = strings.Split(number, "#")[0] |
||||
return strconv.Atoi(number) |
||||
} |
||||
|
||||
// FetchIssues fetches all issues from GitHub
|
||||
func (fm *FetchMigratory) FetchIssues() ([]*github.Issue, error) { |
||||
opt := &github.IssueListByRepoOptions{ |
||||
Sort: "created", |
||||
Direction: "asc", |
||||
State: "all", |
||||
ListOptions: github.ListOptions{ |
||||
PerPage: 100, |
||||
}, |
||||
} |
||||
var allIssues = make([]*github.Issue, 0) |
||||
for { |
||||
issues, resp, err := fm.GHClient.Issues.ListByRepo(fm.ctx(), fm.RepoOwner, fm.RepoName, opt) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("error while listing repos: %v", err) |
||||
} |
||||
allIssues = append(allIssues, issues...) |
||||
if resp.NextPage == 0 { |
||||
break |
||||
} |
||||
opt.Page = resp.NextPage |
||||
} |
||||
return allIssues, nil |
||||
} |
||||
|
||||
// FetchComments fetches all comments from GitHub
|
||||
func (fm *FetchMigratory) FetchComments() ([]*github.IssueComment, error) { |
||||
var allComments = make([]*github.IssueComment, 0) |
||||
opt := &github.IssueListCommentsOptions{ |
||||
Sort: "created", |
||||
Direction: "asc", |
||||
ListOptions: github.ListOptions{ |
||||
PerPage: 100, |
||||
}, |
||||
} |
||||
for { |
||||
comments, resp, err := fm.GHClient.Issues.ListComments(fm.ctx(), fm.RepoOwner, fm.RepoName, 0, opt) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("error while listing repos: %v", err) |
||||
} |
||||
allComments = append(allComments, comments...) |
||||
if resp.NextPage == 0 { |
||||
break |
||||
} |
||||
opt.Page = resp.NextPage |
||||
} |
||||
return allComments, nil |
||||
} |
||||
|
||||
func (fm *FetchMigratory) fetchCommentsAsync() chan *[]*github.IssueComment { |
||||
ret := make(chan *[]*github.IssueComment, 1) |
||||
go func(f *FetchMigratory) { |
||||
comments, err := f.FetchComments() |
||||
if err != nil { |
||||
f.Status.FatalError = err |
||||
ret <- nil |
||||
logrus.WithFields(logrus.Fields{ |
||||
"repo": fmt.Sprintf("%s/%s", fm.RepoOwner, fm.RepoName), |
||||
}).Fatalf("fetching comments failed: %v", fm.Status.FatalError) |
||||
return |
||||
} |
||||
f.Status.Comments = int64(len(comments)) |
||||
ret <- &comments |
||||
}(fm) |
||||
return ret |
||||
} |
@ -0,0 +1,55 @@ |
||||
package migrations |
||||
|
||||
import ( |
||||
"testing" |
||||
|
||||
"github.com/google/go-github/github" |
||||
"github.com/stretchr/testify/assert" |
||||
) |
||||
|
||||
func BenchmarkGetIssueIndexFromHTMLURLAlt(b *testing.B) { |
||||
for i := 0; i <= b.N; i++ { |
||||
getIssueIndexFromHTMLURLAlt("https://github.com/octocat/Hello-World/issues/1347#issuecomment-1") |
||||
} |
||||
} |
||||
|
||||
func TestGetIssueIndexFromHTMLURLAlt(t *testing.T) { |
||||
res, err := getIssueIndexFromHTMLURLAlt("https://github.com/octocat/Hello-World/issues/1347#issuecomment-1") |
||||
assert.NoError(t, err) |
||||
assert.Equal(t, 1347, res) |
||||
res, err = getIssueIndexFromHTMLURLAlt("https://github.com/oment-1") |
||||
assert.Error(t, err) |
||||
} |
||||
|
||||
func BenchmarkGetIssueIndexFromHTMLURL(b *testing.B) { |
||||
for i := 0; i <= b.N; i++ { |
||||
getIssueIndexFromHTMLURL("https://github.com/octocat/Hello-World/issues/1347#issuecomment-1") |
||||
} |
||||
} |
||||
|
||||
func TestGetIssueIndexFromHTMLURL(t *testing.T) { |
||||
res, err := getIssueIndexFromHTMLURL("https://github.com/octocat/Hello-World/issues/1347#issuecomment-1") |
||||
assert.NoError(t, err) |
||||
assert.Equal(t, 1347, res) |
||||
res, err = getIssueIndexFromHTMLURL("https://github.com/oment-1") |
||||
assert.Error(t, err) |
||||
} |
||||
|
||||
var testFMig = &FetchMigratory{ |
||||
Migratory: *DemoMigratory, |
||||
GHClient: github.NewClient(nil), |
||||
RepoOwner: "JonasFranzDEV", |
||||
RepoName: "test", |
||||
} |
||||
|
||||
func TestFetchMigratory_FetchIssues(t *testing.T) { |
||||
issues, err := testFMig.FetchIssues() |
||||
assert.NoError(t, err) |
||||
assert.True(t, len(issues) > 0, "at least one issue found") |
||||
} |
||||
|
||||
func TestFetchMigratory_FetchComments(t *testing.T) { |
||||
comments, err := testFMig.FetchIssues() |
||||
assert.NoError(t, err) |
||||
assert.True(t, len(comments) > 0, "at least one comment found") |
||||
} |
@ -0,0 +1,114 @@ |
||||
package migrations |
||||
|
||||
import ( |
||||
"fmt" |
||||
"strings" |
||||
|
||||
"code.gitea.io/sdk/gitea" |
||||
"github.com/google/go-github/github" |
||||
) |
||||
|
||||
// Job manages all migrations of a "migartion job"
|
||||
type Job struct { |
||||
Repositories []string |
||||
Options *Options |
||||
Client *gitea.Client |
||||
GHClient *github.Client |
||||
|
||||
migratories map[string]*Migratory |
||||
} |
||||
|
||||
// JobReport represents the current status of a Job
|
||||
type JobReport struct { |
||||
Pending []string `json:"pending"` |
||||
Running map[string]*MigratoryStatus `json:"running"` |
||||
Finished map[string]*MigratoryStatus `json:"finished"` |
||||
Failed map[string]string `json:"failed"` |
||||
} |
||||
|
||||
// NewJob returns an instance of initialized instance of Job
|
||||
func NewJob(options *Options, client *gitea.Client, githubClient *github.Client, repos ...string) *Job { |
||||
return &Job{Repositories: repos, Options: options, Client: client, GHClient: githubClient} |
||||
} |
||||
|
||||
// StatusReport generates a JobReport indicating which state the job is
|
||||
func (job *Job) StatusReport() *JobReport { |
||||
report := &JobReport{ |
||||
Pending: make([]string, 0), |
||||
Finished: make(map[string]*MigratoryStatus), |
||||
Running: make(map[string]*MigratoryStatus), |
||||
Failed: make(map[string]string), |
||||
} |
||||
for _, repo := range job.Repositories { |
||||
if migratory, ok := job.migratories[repo]; ok { |
||||
switch migratory.Status.Stage { |
||||
case Finished: |
||||
report.Finished[repo] = migratory.Status |
||||
case Importing: |
||||
case Migrating: |
||||
report.Running[repo] = migratory.Status |
||||
case Failed: |
||||
report.Failed[repo] = migratory.Status.FatalError.Error() |
||||
default: |
||||
report.Pending = append(report.Pending, repo) |
||||
fmt.Printf("unknown status %d\n", migratory.Status.Stage) |
||||
} |
||||
} else { |
||||
report.Pending = append(report.Pending, repo) |
||||
} |
||||
} |
||||
return report |
||||
} |
||||
|
||||
// StartMigration migrates all repos from Repositories
|
||||
func (job *Job) StartMigration() chan error { |
||||
errs := make(chan error, len(job.Repositories)) |
||||
var pendingRepos = len(job.Repositories) |
||||
autoclose := func() { |
||||
pendingRepos-- |
||||
if pendingRepos <= 0 { |
||||
close(errs) |
||||
} |
||||
} |
||||
job.migratories = make(map[string]*Migratory, pendingRepos) |
||||
for _, repo := range job.Repositories { |
||||
mig, err := job.initFetchMigratory(repo) |
||||
job.migratories[repo] = &mig.Migratory |
||||
if err != nil { |
||||
mig.Status = &MigratoryStatus{ |
||||
Stage: Failed, |
||||
FatalError: err, |
||||
} |
||||
errs <- err |
||||
autoclose() |
||||
continue |
||||
} |
||||
go func() { |
||||
err := mig.MigrateFromGitHub() |
||||
errs <- err |
||||
autoclose() |
||||
}() |
||||
} |
||||
return errs |
||||
} |
||||
|
||||
func (job *Job) initFetchMigratory(repo string) (*FetchMigratory, error) { |
||||
res := strings.Split(repo, "/") |
||||
if len(res) != 2 { |
||||
return nil, fmt.Errorf("invalid repo name: %s", repo) |
||||
} |
||||
return &FetchMigratory{ |
||||
Migratory: Migratory{ |
||||
Client: job.Client, |
||||
Options: *job.Options, |
||||
}, |
||||
RepoName: res[1], |
||||
RepoOwner: res[0], |
||||
GHClient: job.GHClient, |
||||
}, nil |
||||
} |
||||
|
||||
// Finished indicates if the job is finished or not
|
||||
func (job *Job) Finished() bool { |
||||
return (len(job.StatusReport().Failed) + len(job.StatusReport().Finished)) >= len(job.Repositories) |
||||
} |
@ -0,0 +1,66 @@ |
||||
package migrations |
||||
|
||||
import ( |
||||
"fmt" |
||||
"testing" |
||||
|
||||
"github.com/stretchr/testify/assert" |
||||
) |
||||
|
||||
func TestJob_StatusReport(t *testing.T) { |
||||
jobWithStatus := func(status *MigratoryStatus) *Job { |
||||
return &Job{ |
||||
migratories: map[string]*Migratory{ |
||||
"test/test": { |
||||
Status: status, |
||||
}, |
||||
}, |
||||
Repositories: []string{ |
||||
"test/test", |
||||
}, |
||||
} |
||||
} |
||||
// Pending
|
||||
pendingJob := &Job{ |
||||
Repositories: []string{ |
||||
"test/test", |
||||
}, |
||||
} |
||||
report := pendingJob.StatusReport() |
||||
assert.Len(t, report.Pending, 1) |
||||
assert.Equal(t, report.Pending[0], "test/test") |
||||
assert.Len(t, report.Failed, 0) |
||||
assert.Len(t, report.Running, 0) |
||||
assert.Len(t, report.Finished, 0) |
||||
|
||||
// Finished
|
||||
report = jobWithStatus(&MigratoryStatus{ |
||||
Stage: Finished, |
||||
}).StatusReport() |
||||
assert.Len(t, report.Pending, 0) |
||||
assert.Len(t, report.Failed, 0) |
||||
assert.Len(t, report.Running, 0) |
||||
assert.Len(t, report.Finished, 1) |
||||
assert.Equal(t, Finished, report.Finished["test/test"].Stage) |
||||
|
||||
// Failed
|
||||
report = jobWithStatus(&MigratoryStatus{ |
||||
Stage: Failed, |
||||
FatalError: fmt.Errorf("test"), |
||||
}).StatusReport() |
||||
assert.Len(t, report.Failed, 1) |
||||
assert.Equal(t, "test", report.Failed["test/test"]) |
||||
assert.Len(t, report.Pending, 0) |
||||
assert.Len(t, report.Running, 0) |
||||
assert.Len(t, report.Finished, 0) |
||||
|
||||
// Running
|
||||
report = jobWithStatus(&MigratoryStatus{ |
||||
Stage: Migrating, |
||||
}).StatusReport() |
||||
assert.Len(t, report.Running, 1) |
||||
assert.Equal(t, Migrating, report.Running["test/test"].Stage) |
||||
assert.Len(t, report.Pending, 0) |
||||
assert.Len(t, report.Failed, 0) |
||||
assert.Len(t, report.Finished, 0) |
||||
} |
@ -0,0 +1,32 @@ |
||||
package migrations |
||||
|
||||
// Options defines the way a repository gets migrated
|
||||
type Options struct { |
||||
Issues bool |
||||
Milestones bool |
||||
Labels bool |
||||
Comments bool |
||||
PullRequests bool |
||||
|
||||
AuthUsername string |
||||
AuthPassword string |
||||
|
||||
Private bool |
||||
NewOwnerID int |
||||
|
||||
Strategy Strategy |
||||
} |
||||
|
||||
// Strategy represents the procedure of migration.
|
||||
type Strategy int |
||||
|
||||
const ( |
||||
// Classic works for all Gitea versions and creates comments by the user migrating the repository. This does not require
|
||||
// admin permissions. The issue "number" is also assinged by Gitea and could be different to the GitHub issue "number".
|
||||
// Creation date of comments, issues, milestones, etc. will be the date of creation.
|
||||
Classic Strategy = iota |
||||
// Advanced works for all Gitea versions 1.6+ and utilizes the Gitea Migration API which allows the tool to create comments
|
||||
// with Ghost Users. Creation date and issue numbers will be the same like GitHub. It requires admin permissions for repo
|
||||
// (creation date, issue number) and/or
|
||||
Advanced |
||||
) |
@ -1,24 +1,38 @@ |
||||
package migrations |
||||
|
||||
import ( |
||||
"strings" |
||||
"testing" |
||||
"time" |
||||
|
||||
"code.gitea.io/sdk/gitea" |
||||
"github.com/stretchr/testify/assert" |
||||
) |
||||
|
||||
// DemoMigratory is been used for testing
|
||||
var DemoMigratory = &Migratory{ |
||||
AuthUsername: "demo", |
||||
AuthPassword: "demo", |
||||
Client: gitea.NewClient("http://gitea:3000", "8bffa364d5a4b2f18421426da0baf6ccddd16d6b"), |
||||
Options: Options{ |
||||
AuthUsername: "demo", |
||||
AuthPassword: "demo", |
||||
NewOwnerID: 1, |
||||
}, |
||||
Client: gitea.NewClient("http://gitea:3000", "8bffa364d5a4b2f18421426da0baf6ccddd16d6b"), |
||||
repository: &gitea.Repository{ |
||||
Name: "demo", |
||||
Owner: &gitea.User{ |
||||
UserName: "demo", |
||||
}, |
||||
}, |
||||
NewOwnerID: 1, |
||||
migratedMilestones: make(map[int64]int64), |
||||
migratedLabels: make(map[int64]int64), |
||||
} |
||||
|
||||
var demoTime = time.Date(2018, 01, 01, 01, 01, 01, 01, time.UTC) |
||||
|
||||
func assertNoError(t *testing.T, err error) { |
||||
if err != nil && strings.Contains(err.Error(), "lookup gitea") { |
||||
t.Skip("gitea instance is not running") |
||||
} else { |
||||
assert.NoError(t, err) |
||||
} |
||||
} |
||||
|
@ -0,0 +1,50 @@ |
||||
package auth |
||||
|
||||
import ( |
||||
"code.gitea.io/sdk/gitea" |
||||
"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/web/context" |
||||
) |
||||
|
||||
// GiteaLoginForm represents the data required for logging in into gitea
|
||||
type GiteaLoginForm struct { |
||||
Username string `form:"username"` |
||||
Password string `form:"password"` |
||||
AccessToken string `form:"access-token"` |
||||
GiteaURL string `form:"gitea-url"` |
||||
Type string `form:"use" binding:"Required;In(token,password)"` |
||||
} |
||||
|
||||
// LoginToGitea handles the POST request for signing in with a Gitea account
|
||||
func LoginToGitea(ctx *context.Context, form GiteaLoginForm) { |
||||
var token string |
||||
if form.Type == "password" { |
||||
client := gitea.NewClient(form.GiteaURL, "") |
||||
tkn, err := client.CreateAccessToken(form.Username, form.Password, gitea.CreateAccessTokenOption{ |
||||
Name: "gitea-github-migrator", |
||||
}) |
||||
if err != nil { |
||||
ctx.Flash.Error("Cannot create access token please check your credentials!") |
||||
ctx.Redirect("/") |
||||
return |
||||
} |
||||
token = tkn.Sha1 |
||||
} else { |
||||
token = form.AccessToken |
||||
} |
||||
client := gitea.NewClient(form.GiteaURL, token) |
||||
usr, err := client.GetMyUserInfo() |
||||
if err != nil { |
||||
ctx.Flash.Error("Invalid Gitea credentials.") |
||||
ctx.Redirect("/") |
||||
return |
||||
} |
||||
ctx.Session.Set("gitea_user", &context.User{ |
||||
ID: usr.ID, |
||||
Username: usr.UserName, |
||||
Token: token, |
||||
AvatarURL: usr.AvatarURL, |
||||
}) |
||||
ctx.Session.Set("gitea", form.GiteaURL) |
||||
ctx.Redirect("/") |
||||
return |
||||
} |
@ -0,0 +1,68 @@ |
||||
package auth |
||||
|
||||
import ( |
||||
"context" |
||||
"net/http" |
||||
|
||||
"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/config" |
||||
webcontext "git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/web/context" |
||||
"github.com/adam-hanna/randomstrings" |
||||
"github.com/go-macaron/session" |
||||
"github.com/google/go-github/github" |
||||
"golang.org/x/oauth2" |
||||
githubauth "golang.org/x/oauth2/github" |
||||
) |
||||
|
||||
var ( |
||||
githubOAuthConfig *oauth2.Config |
||||
) |
||||
|
||||
// InitGitHubOAuthConfig loads values from config into githubOAuthConfig
|
||||
func InitGitHubOAuthConfig() { |
||||
githubOAuthConfig = &oauth2.Config{ |
||||
ClientID: config.Config.GitHub.ClientID, |
||||
ClientSecret: config.Config.GitHub.ClientSecret, |
||||
Scopes: []string{"repo"}, |
||||
Endpoint: githubauth.Endpoint, |
||||
} |
||||
} |
||||
|
||||
// RedirectToGitHub returns the redirect URL for github
|
||||
func RedirectToGitHub(ctx *webcontext.Context, session session.Store) { |
||||
state, err := randomstrings.GenerateRandomString(64) |
||||
if err != nil { |
||||
return |
||||
} |
||||
session.Set("state", state) |
||||
ctx.Redirect(githubOAuthConfig.AuthCodeURL(state), http.StatusTemporaryRedirect) |
||||
} |
||||
|
||||
// CallbackFromGitHub handles the callback from the GitHub OAuth provider
|
||||
func CallbackFromGitHub(ctx *webcontext.Context, session session.Store) { |
||||
bg := context.Background() |
||||
var state string |
||||
var ok bool |
||||
if state, ok = session.Get("state").(string); state == "" || !ok || state != ctx.Query("state") { |
||||
ctx.Handle(400, "invalid session", nil) |
||||
return |
||||
} |
||||
token, err := githubOAuthConfig.Exchange(bg, ctx.Query("code")) |
||||
if err != nil { |
||||
ctx.Handle(403, "access denied", err) |
||||
return |
||||
} |
||||
tc := oauth2.NewClient(bg, oauth2.StaticTokenSource(token)) |
||||
client := github.NewClient(tc) |
||||
user, _, err := client.Users.Get(bg, "") |
||||
if err != nil { |
||||
ctx.Handle(403, "access denied", err) |
||||
return |
||||
} |
||||
session.Set("user", &webcontext.User{ |
||||
ID: user.GetID(), |
||||
AvatarURL: *user.AvatarURL, |
||||
Username: user.GetLogin(), |
||||
Token: token.AccessToken, |
||||
}) |
||||
ctx.Redirect("/") |
||||
} |
@ -0,0 +1,115 @@ |
||||
package context |
||||
|
||||
import ( |
||||
bgctx "context" |
||||
"fmt" |
||||
"strings" |
||||
|
||||
"code.gitea.io/sdk/gitea" |
||||
"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/config" |
||||
"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/migrations" |
||||
"github.com/go-macaron/session" |
||||
"github.com/google/go-github/github" |
||||
"github.com/sirupsen/logrus" |
||||
"golang.org/x/oauth2" |
||||
"gopkg.in/macaron.v1" |
||||
) |
||||
|
||||
// Context represents context of a request.
|
||||
type Context struct { |
||||
*macaron.Context |
||||
Flash *session.Flash |
||||
Session session.Store |
||||
|
||||
Client *github.Client |
||||
GiteaClient *gitea.Client |
||||
User *User //GitHub user
|
||||
GiteaUser *User |
||||
Link string // current request URL
|
||||
} |
||||
|
||||
// User is an abstraction of a Gitea or GitHub user, saving the required information
|
||||
type User struct { |
||||
ID int64 |
||||
Username string |
||||
AvatarURL string |
||||
Token string |
||||
} |
||||
|
||||
var runningJobs = make(map[string]*migrations.Job) |
||||
|
||||
// GetCurrentJob returns the current job of the user
|
||||
// Bug(JonasFranzDEV): prevents scalability (FIXME)
|
||||
func (ctx *Context) GetCurrentJob() *migrations.Job { |
||||
return runningJobs[ctx.Session.ID()] |
||||
} |
||||
|
||||
// SetCurrentJob sets the current job of the user
|
||||
// Bug(JonasFranzDEV): prevents scalability (FIXME)
|
||||
func (ctx *Context) SetCurrentJob(job *migrations.Job) { |
||||
runningJobs[ctx.Session.ID()] = job |
||||
} |
||||
|
||||
// Handle displays the corresponding error message
|
||||
func (ctx *Context) Handle(status int, title string, err error) { |
||||
if err != nil { |
||||
if macaron.Env != macaron.PROD { |
||||
ctx.Data["ErrorMsg"] = err |
||||
} |
||||
} |
||||
logrus.Warnf("Handle: %v", err) |
||||
ctx.Data["ErrTitle"] = title |
||||
|
||||
switch status { |
||||
case 403: |
||||
ctx.Data["Title"] = "Access denied" |
||||
case 404: |
||||
ctx.Data["Title"] = "Page not found" |
||||
case 500: |
||||
ctx.Data["Title"] = "Internal Server Error" |
||||
default: |
||||
ctx.Context.HTML(status, "status/unknown_error") |
||||
return |
||||
} |
||||
ctx.Context.HTML(status, fmt.Sprintf("status/%d", status)) |
||||
} |
||||
|
||||
// Contexter injects context.Context into macaron
|
||||
func Contexter() macaron.Handler { |
||||
return func(c *macaron.Context, sess session.Store, f *session.Flash) { |
||||
ctx := &Context{ |
||||
Context: c, |
||||
Flash: f, |
||||
Session: sess, |
||||
Link: c.Req.URL.String(), |
||||
} |
||||
c.Data["Link"] = ctx.Link |
||||
if ctx.Req.Method == "POST" && strings.Contains(ctx.Req.Header.Get("Content-Type"), "multipart/form-data") { |
||||
if err := ctx.Req.ParseMultipartForm(5242880); err != nil && |
||||
strings.Contains(err.Error(), "EOF") { |
||||
ctx.Handle(500, "ParseMultipartForm", err) |
||||
} |
||||
} |
||||
ctx.Data["Config"] = config.Config |
||||
usr := sess.Get("user") |
||||
if usr != nil { |
||||
ctx.User = usr.(*User) |
||||
ctx.Data["User"] = ctx.User |
||||
} |
||||
giteaUsr := sess.Get("gitea_user") |
||||
if giteaUsr != nil { |
||||
ctx.GiteaUser = giteaUsr.(*User) |
||||
ctx.Data["GiteaUser"] = ctx.GiteaUser |
||||
} |
||||
if ctx.User != nil && ctx.User.Token != "" { |
||||
tc := oauth2.NewClient(bgctx.Background(), oauth2.StaticTokenSource(&oauth2.Token{AccessToken: ctx.User.Token})) |
||||
ctx.Client = github.NewClient(tc) |
||||
} else { |
||||
ctx.Client = github.NewClient(nil) |
||||
} |
||||
if giteaURL, ok := sess.Get("gitea").(string); ok && giteaURL != "" && ctx.GiteaUser != nil && ctx.GiteaUser.Token != "" { |
||||
ctx.GiteaClient = gitea.NewClient(giteaURL, ctx.GiteaUser.Token) |
||||
} |
||||
c.Map(ctx) |
||||
} |
||||
} |
@ -0,0 +1,58 @@ |
||||
package web |
||||
|
||||
import ( |
||||
"bytes" |
||||
"io" |
||||
"path" |
||||
"strings" |
||||
|
||||
"github.com/gobuffalo/packr" |
||||
"gopkg.in/macaron.v1" |
||||
) |
||||
|
||||
// BundledFS implements ServeFileSystem for packr.Box
|
||||
type BundledFS struct { |
||||
packr.Box |
||||
} |
||||
|
||||
// Exists returns true if filepath exists
|
||||
func (fs *BundledFS) Exists(prefix string, filepath string) bool { |
||||
if p := strings.TrimPrefix(filepath, prefix); len(p) < len(filepath) { |
||||
return fs.Has(p) |
||||
} |
||||
return false |
||||
} |
||||
|
||||
// ListFiles returns all files in FS
|
||||
func (fs *BundledFS) ListFiles() (files []macaron.TemplateFile) { |
||||
for _, filename := range fs.List() { |
||||
files = append(files, &BundledFile{fs: fs, FileName: filename}) |
||||
} |
||||
return files |
||||
} |
||||
|
||||
// Get returns the content of filename
|
||||
func (fs *BundledFS) Get(filename string) (io.Reader, error) { |
||||
return bytes.NewReader(fs.Bytes(filename)), nil |
||||
} |
||||
|
||||
// BundledFile represents a file in a BundledFS
|
||||
type BundledFile struct { |
||||
fs *BundledFS |
||||
FileName string |
||||
} |
||||
|
||||
// Name represents the name of the file
|
||||
func (b *BundledFile) Name() string { |
||||
return strings.TrimSuffix(b.FileName, path.Ext(b.FileName)) |
||||
} |
||||
|
||||
// Data returns the content of file
|
||||
func (b *BundledFile) Data() []byte { |
||||
return b.fs.Bytes(b.FileName) |
||||
} |
||||
|
||||
// Ext returns the file extension
|
||||
func (b *BundledFile) Ext() string { |
||||
return path.Ext(b.FileName) |
||||
} |
@ -0,0 +1,62 @@ |
||||
package migration |
||||
|
||||
import ( |
||||
bgctx "context" |
||||
"regexp" |
||||
"strings" |
||||
|
||||
"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/migrations" |
||||
"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/web/context" |
||||
"github.com/google/go-github/github" |
||||
) |
||||
|
||||
const repoRegex = "^[A-Za-z0-9-.]+/[A-Za-z0-9-.]+$" |
||||
|
||||
// ListRepos shows all available repos of the signed in user
|
||||
func ListRepos(ctx *context.Context) { |
||||
repos, _, err := ctx.Client.Repositories.List(bgctx.Background(), ctx.User.Username, &github.RepositoryListOptions{ |
||||
ListOptions: github.ListOptions{ |
||||
PerPage: 100, |
||||
}, |
||||
}) |
||||
|
||||
if err != nil { |
||||
ctx.Handle(500, "list repositories", err) |
||||
return |
||||
} |
||||
ctx.Data["Repos"] = repos |
||||
ctx.HTML(200, "repos") |
||||
} |
||||
|
||||
// ListReposPost handles the form submission of ListRepos
|
||||
func ListReposPost(ctx *context.Context) { |
||||
if err := ctx.Req.ParseForm(); err != nil { |
||||
ctx.Handle(500, "parse form", err) |
||||
return |
||||
} |
||||
// TODO implement migration options
|
||||
job := migrations.NewJob(&migrations.Options{ |
||||
Labels: true, |
||||
Comments: true, |
||||
Issues: true, |
||||
Milestones: true, |
||||
PullRequests: true, |
||||
Strategy: migrations.Classic, |
||||
NewOwnerID: int(ctx.GiteaUser.ID), // TODO implement user/org selection
|
||||
}, ctx.GiteaClient, ctx.Client) |
||||
for repo, val := range ctx.Req.Form { |
||||
activated := strings.Join(val, "") |
||||
if activated != "on" { |
||||
continue |
||||
} |
||||
// Validate repo format (reponame/owner)
|
||||
if matched, err := regexp.MatchString(repoRegex, repo); err != nil || !matched { |
||||
continue |
||||
} |
||||
job.Repositories = append(job.Repositories, repo) |
||||
} |
||||
go job.StartMigration() |
||||
ctx.SetCurrentJob(job) |
||||
ctx.Data["StatusReport"] = job.StatusReport() |
||||
ctx.HTML(200, "migration") |
||||
} |
@ -0,0 +1,14 @@ |
||||
package migration |
||||
|
||||
import ( |
||||
"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/web/context" |
||||
) |
||||
|
||||
// StatusReport returns the status of the current Job in JSON
|
||||
func StatusReport(ctx *context.Context) { |
||||
if job := ctx.GetCurrentJob(); job != nil { |
||||
ctx.JSON(200, job.StatusReport()) |
||||
return |
||||
} |
||||
ctx.Status(404) |
||||
} |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,121 @@ |
||||
var done = false; |
||||
|
||||
function update() { |
||||
$.getJSON("/status", function(data) { |
||||
handleData(data); |
||||
}).always(function() { |
||||
if(!done){ |
||||
window.setTimeout(update, 1000); |
||||
} |
||||
}); |
||||
} |
||||
$(function() { |
||||
$(".repo-progress").progress({ |
||||
text: { |
||||
active : 'Migrated or failed {value} of {total} repositories', |
||||
success : '{total} repositories migrated or failed!' |
||||
}, |
||||
total: $(".repo-progress").data("total"), |
||||
value: 0 |
||||
}); |
||||
}); |
||||
|
||||
function handleData(data) { |
||||
if(Object.keys(data.finished).length + Object.keys(data.failed).length === $(".repo-progress").progress('get total')) { |
||||
$(".repo-progress").progress('complete'); |
||||
done = true; |
||||
} else { |
||||
$(".repo-progress").progress('set progress', Object.keys(data.finished).length + Object.keys(data.failed).length); |
||||
} |
||||
data.pending.forEach(function(repo) { |
||||
var content = contentFromRepo(repo); |
||||
if (!content.hasClass("pending")) { |
||||
content.html(renderPending().html()); |
||||
content.addClass("pending"); |
||||
} |
||||
}); |
||||
forEach(data.failed, function (repo, errormsg) { |
||||
var content = contentFromRepo(repo); |
||||
if (!content.hasClass("failed")) { |
||||
content.html(renderFailed(errormsg).html()); |
||||
content.addClass("failed"); |
||||
} |
||||
}); |
||||
forEach(data.running, function (repo, report) { |
||||
var content = handleNonPending(repo, report); |
||||
if (content.find(".comment-progress").progress('get total') !== report.total_comments) { |
||||
content.find(".comment-progress").progress('set total', report.total_comments) |
||||
} |
||||
if (content.find(".issue-progress").progress('get total') !== report.total_issues) { |
||||
content.find(".issue-progress").progress('set total', report.total_issues) |
||||
} |
||||
content.find(".comment-progress").progress('set progress', report.migrated_comments + report.failed_comments); |
||||
content.find(".issue-progress").progress('set progress', report.migrated_issues + report.failed_issues); |
||||
}); |
||||
forEach(data.finished, function (repo, report) { |
||||
var content = handleNonPending(repo, report); |
||||
if (content.find(".comment-progress").progress('get total') !== report.total_comments) { |
||||
content.find(".comment-progress").progress('set total', report.total_comments) |
||||
} |
||||
if (content.find(".issue-progress").progress('get total') !== report.total_issues) { |
||||
content.find(".issue-progress").progress('set total', report.total_issues) |
||||
} |
||||
content.find(".comment-progress").progress('set progress', report.migrated_comments + report.failed_comments); |
||||
content.find(".issue-progress").progress('set progress', report.migrated_issues + report.failed_issues); |
||||
content.find(".issue-progress").progress('complete'); |
||||
content.find(".comment-progress").progress('complete'); |
||||
}); |
||||
} |
||||
function forEach(object, callback) { |
||||
for(var prop in object) { |
||||
if(object.hasOwnProperty(prop)) { |
||||
callback(prop, object[prop]); |
||||
} |
||||
} |
||||
} |
||||
|
||||
function handleNonPending(repo, report) { |
||||
var content = contentFromRepo(repo); |
||||
if(!content.hasClass("non-pending")) { |
||||
content.html(renderNonPending().html()); |
||||
content.find(".issue-progress").progress({ |
||||
text: { |
||||
active : 'Migrated {value} of {total} issues', |
||||
success : '{total} issues migrated!' |
||||
}, |
||||
total: report.total_issues, |
||||
value: report.migrated_issues + report.failed_issues, |
||||
}); |
||||
content.find(".comment-progress").progress({ |
||||
text: { |
||||
active : 'Migrated {value} of {total} comments', |
||||
success : '{total} comments migrated!' |
||||
}, |
||||
total: report.total_comments+1, |
||||
value: report.migrated_comments + report.failed_comments, |
||||
}); |
||||
content.addClass("non-pending"); |
||||
} |
||||
content.find(".failed-issues").text(report.failed_issues); |
||||
content.find(".failed-comments").text(report.failed_comments); |
||||
return content |
||||
} |
||||
|
||||
function contentFromRepo(repo) { |
||||
return $(".repo-content[data-repo='" + repo + "']") |
||||
} |
||||
|
||||
function renderPending() { |
||||
return $("#content-pending").clone(); |
||||
} |
||||
|
||||
function renderFailed(errormsg) { |
||||
var failed = $("#content-failed").clone(); |
||||
failed.find(".errormsg").text(errormsg); |
||||
return failed |
||||
} |
||||
function renderNonPending() { |
||||
return $("#content-nonpending").clone(); |
||||
} |
||||
|
||||
$(update()); |
@ -0,0 +1,30 @@ |
||||
var repo_regex = /^[A-Za-z0-9-.]+\/[A-Za-z0-9-.]+$/; |
||||
|
||||
function openSelectRepoModal() { |
||||
$("#add-repos").modal('setting', { |
||||
onApprove: function () { |
||||
var repos = parseReposInTextArea(); |
||||
for (var idx in repos) { |
||||
var repo = repos[idx]; |
||||
if (repo_regex.test(repo)){ |
||||
addRepoToList(repo); |
||||
}else { |
||||
alert(repo + " is not a repository") |
||||
} |
||||
} |
||||
return true; |
||||
} |
||||
}).modal('show'); |
||||
} |
||||
|
||||
function parseReposInTextArea() { |
||||
var text = $("#repo-textform").val(); |
||||
return text.split("\n"); |
||||
} |
||||
|
||||
function addRepoToList(repo) { |
||||
var item = $("#repo-item").children('.item').clone(); |
||||
item.html(item.html().replace(/FULL_REPO_NAME/g, repo)); |
||||
console.log(repo, item.html()); |
||||
$("#repo-list").append(item); |
||||
} |
File diff suppressed because one or more lines are too long
@ -0,0 +1,68 @@ |
||||
package web |
||||
|
||||
import ( |
||||
"encoding/gob" |
||||
|
||||
"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/web/auth" |
||||
"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/web/context" |
||||
"git.jonasfranz.software/JonasFranzDEV/gitea-github-migrator/web/migration" |
||||
"github.com/go-macaron/binding" |
||||
"github.com/go-macaron/session" |
||||
"github.com/gobuffalo/packr" |
||||
"gopkg.in/macaron.v1" |
||||
) |
||||
|
||||
// InitRoutes initiates the gin routes and loads values from config
|
||||
func InitRoutes() *macaron.Macaron { |
||||
gob.Register(&context.User{}) |
||||
m := macaron.Classic() |
||||
auth.InitGitHubOAuthConfig() |
||||
tmplBox := packr.NewBox("templates") |
||||
publicBox := packr.NewBox("public") |
||||
m.Use(macaron.Recovery()) |
||||
m.Use(session.Sessioner(session.Options{ |
||||
Provider: "file", |
||||
ProviderConfig: "data/sessions", |
||||
})) |
||||
m.Use(macaron.Renderer(macaron.RenderOptions{ |
||||
TemplateFileSystem: &BundledFS{tmplBox}, |
||||
})) |
||||
m.Use(macaron.Statics(macaron.StaticOptions{ |
||||
Prefix: "static", |
||||
FileSystem: publicBox, |
||||
}, "")) |
||||
m.Use(context.Contexter()) |
||||
|
||||
// BEGIN: Router
|
||||
m.Get("/", func(ctx *context.Context) { |
||||
if ctx.User != nil { |
||||
if ctx.GiteaUser == nil { |
||||
ctx.HTML(200, "login_gitea") |
||||
return |
||||
} |
||||
ctx.HTML(200, "dashboard") |
||||
return |
||||
} |
||||
ctx.HTML(200, "login_github") // 200 is the response code.
|
||||
}) |
||||
m.Get("/logout", func(c *macaron.Context, sess session.Store) { |
||||
sess.Destory(c) |
||||
c.Redirect("/") |
||||
}) |
||||
m.Group("/github", func() { |
||||
m.Get("/", auth.RedirectToGitHub) |
||||
m.Get("/callback", auth.CallbackFromGitHub) |
||||
}) |
||||
m.Group("/gitea", func() { |
||||
m.Post("/", binding.BindIgnErr(auth.GiteaLoginForm{}), auth.LoginToGitea) |
||||
}) |
||||
m.Combo("/repos", reqSignIn).Get(migration.ListRepos).Post(migration.ListReposPost) |
||||
m.Get("/status", reqSignIn, migration.StatusReport) |
||||
return m |
||||
} |
||||
|
||||
func reqSignIn(ctx *context.Context) { |
||||
if ctx.User == nil || ctx.GiteaUser == nil { |
||||
ctx.Redirect("/") |
||||
} |
||||
} |
@ -0,0 +1,2 @@ |
||||
</body> |
||||
</html> |
@ -0,0 +1,34 @@ |
||||
<!DOCTYPE html> |
||||
<html> |
||||
<head> |
||||
<!-- Standard Meta --> |
||||
<meta charset="utf-8" /> |
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" /> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"> |
||||
|
||||
<!-- Site Properties --> |
||||
<title>Gitea Migrator</title> |
||||
|
||||
<script src="/static/js/jquery-3.1.1.min.js"></script> |
||||
<script src="/static/js/semantic.min.js"></script> |
||||
<link href="/static/css/semantic.min.css" rel="stylesheet"> |
||||
<style type="text/css"> |
||||
body { |
||||
background-color: #DADADA; |
||||
padding: 30px; |
||||
} |
||||
body > .grid { |
||||
height: 100%; |
||||
} |
||||
.column { |
||||
max-width: 600px; |
||||
} |
||||
.big { |
||||
max-width: 1000px !important; |
||||
} |
||||
</style> |
||||
</head> |
||||
<body> |
||||
{{if .Flash.ErrorMsg}} |
||||
<div class="ui error message">{{.Flash.ErrorMsg}}</div> |
||||
{{end}} |
@ -0,0 +1,32 @@ |
||||
{{template "base/head" .}} |
||||
<div class="ui middle aligned center aligned grid"> |
||||
<div class="column"> |
||||
<h1 class="ui image header"> |
||||
<div class="content"> |
||||
Gitea Migrator |
||||
</div> |
||||
</h1> |
||||
<div class="ui message"> |
||||
You've connected your GitHub and Gitea account. |
||||
</div> |
||||
<div class="ui icon message"> |
||||
<i class="icon github"></i> |
||||
<div class="content"> |
||||
<h3 class="header">GitHub connected</h3> |
||||
You're logged in as {{template "modules/username" .User}}. <a href="/logout">Not you?</a> |
||||
</div> |
||||
</div> |
||||
<div class="ui icon attached message"> |
||||
<i class="icon lock"></i> |
||||
<div class="content"> |
||||
<h3 class="header">Gitea connected</h3> |
||||
You're logged in as {{template "modules/username" .GiteaUser}}. <a href="/logout">Not you?</a> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="ui stacked segment"> |
||||
<a href="/repos" class="ui fluid large green labeld icon button"><i class="icon list"></i> Migrate Repositories...</a> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{{template "base/footer" .}} |
@ -0,0 +1,67 @@ |
||||
{{template "base/head" .}} |
||||
<div class="ui middle aligned center aligned grid"> |
||||
<div class="column"> |
||||
<h1 class="ui image header"> |
||||
<div class="content"> |
||||
Gitea Migrator |
||||
</div> |
||||
</h1> |
||||
<div class="ui message"> |
||||
You've connected you GitHub account. The next step is to connect to your Gitea instance. |
||||
</div> |
||||
<div class="ui icon message"> |
||||
<i class="icon github"></i> |
||||
<div class="content"> |
||||
<h3 class="header">GitHub connected</h3> |
||||
You're logged in as {{template "modules/username" .User}}. <a href="/logout">Not you?</a> |
||||
</div> |
||||
</div> |
||||
<div class="ui icon attached message"> |
||||
<i class="icon lock"></i> |
||||
<div class="content"> |
||||
<h3 class="header">Login to Gitea</h3> |
||||
You can use your user credentials or an access token to log in to your Gitea instance. If you use your credentials |
||||
an access token will be created for you. |
||||
</div> |
||||
</div> |
||||
<form action="/gitea" method="POST" class="ui large form"> |
||||
<div class="ui attached fluid segment"> |
||||
<div class="field"> |
||||
<label>Gitea URL</label> |
||||
<input name="gitea-url" type="url"> |
||||
</div> |
||||
<h3>Credentials</h3> |
||||
<div class="ui top attached tabular menu"> |
||||
<a class="active item" data-tab="password">Username + Password</a> |
||||
<a class="item" data-tab="access-token">Access Token</a> |
||||
</div> |
||||
<div class="ui bottom attached tab segment active" data-tab="password"> |
||||
<div class="field"> |
||||
<label>Username</label> |
||||
<input name="username" placeholder="Username" type="text"> |
||||
</div> |
||||
<div class="field"> |
||||
<label>Password</label> |
||||
<input name="password" type="password"> |
||||
</div> |
||||
<button type="submit" name="use" value="password" class="ui fluid large green submit button">Login to Gitea</button> |
||||
</div> |
||||
<div class="ui bottom attached tab segment" data-tab="access-token"> |
||||
<div class="field"> |
||||
<label>Access Token</label> |
||||
<input name="access-token" type="password"> |
||||
</div> |
||||
<button type="submit" name="use" value="token" class="ui fluid large green submit button">Login to Gitea</button> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="ui error message"></div> |
||||
</form> |
||||
</div> |
||||
</div> |
||||
<script> |
||||
$('.menu .item') |
||||
.tab() |
||||
; |
||||
</script> |
||||
{{template "base/footer" .}} |
@ -0,0 +1,22 @@ |
||||
{{template "base/head" .}} |
||||
<div class="ui middle aligned center aligned grid"> |
||||
<div class="column"> |
||||
<h1 class="ui image header"> |
||||
<div class="content"> |
||||
Gitea Migrator |
||||
</div> |
||||
</h1> |
||||
<div class="ui message"> |
||||
Migrate all your GitHub repositories to your Gitea instance including all issues, labels and milestones. |
||||
</div> |
||||
<form class="ui large form"> |
||||
<div class="ui stacked segment"> |
||||
<a href="/github" class="ui fluid large green submit labeled icon button"><i class="icon github"></i> Login with GitHub</a> |
||||
</div> |
||||
|
||||
<div class="ui error message"></div> |
||||
|
||||
</form> |
||||
</div> |
||||
</div> |
||||
{{template "base/footer" .}} |
@ -0,0 +1,54 @@ |
||||
{{template "base/head" .}} |
||||
<script src="static/js/repos-status.js"></script> |
||||
<div class="ui middle aligned center aligned grid"> |
||||
<div class="column big"> |
||||
<h1 class="ui image header"> |
||||
<div class="content"> |
||||
Migrating Repositories... |
||||
</div> |
||||
</h1> |
||||
<div class="ui message"> |
||||
Your repositories get migrated at the moment. This page refreshs automatically. |
||||
</div> |
||||
<div class="repo-progress ui progress" data-total="{{len .StatusReport.Pending}}"> |
||||
<div class="bar"></div> |
||||
<div class="label">Migrating repositories</div> |
||||
</div> |
||||
<div class="ui three stackable cards" id="migration-list"> |
||||
{{range .StatusReport.Pending}} |
||||
<div class="ui repo-card card" data-repo="{{.}}" data-status="pending"> |
||||
<div class="content"> |
||||
<div class="header"> |
||||
<i class="icon github"></i>{{.}}</div> |
||||
</div> |
||||
<div class="repo-content content" data-repo="{{.}}"> |
||||
<div class="ui active centered inline small text loader">Pending...</div> |
||||
</div> |
||||
</div> |
||||
{{end}} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div id="content-pending" style="display: none;"> |
||||
<div class="ui active centered inline small text loader">Pending...</div> |
||||
</div> |
||||
<div id="content-nonpending" style="display: none;"> |
||||
<div class="issue-progress ui indicating progress"> |
||||
<div class="bar"></div> |
||||
<div class="label">Migrating issues</div> |
||||
</div> |
||||
<p><b class="failed-issues">0</b> migration(s) of issues failed</p> |
||||
<div class="comment-progress ui indicating progress"> |
||||
<div class="bar"></div> |
||||
<div class="label">Migrating comments</div> |
||||
</div> |
||||
<p><b class="failed-comments">0</b> migration(s) of comments failed</p> |
||||
</div> |
||||
<div id="content-failed" style="display: none;"> |
||||
<div class="ui negative message"> |
||||
<div class="header"> |
||||
Error while migrating |
||||
</div> |
||||
<p class="errormsg"></p> |
||||
</div> |
||||
</div> |
@ -0,0 +1 @@ |
||||
<code>{{.Username}}</code> |
@ -0,0 +1,77 @@ |
||||
{{template "base/head" .}} |
||||
<script src="static/js/select-repos.js"></script> |
||||
<div class="ui middle aligned center aligned grid"> |
||||
<div class="column"> |
||||
<h1 class="ui image header"> |
||||
<div class="content"> |
||||
Migrate Repositories |
||||
</div> |
||||
</h1> |
||||
<div class="ui message"> |
||||
Select the repositories you'd like to migrate. |
||||
</div> |
||||
<div class="ui stacked segment"> |
||||
<form action="/repos" method="POST"> |
||||
<div class="ui horizontal link list"> |
||||
<a class="item" onclick="$('.repo-toggle').prop('checked', true);"> |
||||
Select all |
||||
</a> |
||||
<a class="item" onclick="$('.repo-toggle').prop('checked', false);"> |
||||
Deselect all |
||||
</a> |
||||
</div> |
||||
<div class="ui relaxed divided list" id="repo-list"> |
||||
{{range .Repos}} |
||||
<div class="item"> |
||||
<i class="large github middle aligned icon"></i> |
||||
<div class="content"> |
||||
<div class="ui left toggle checkbox"> |
||||
<input checked id="{{.GetFullName}}" class="repo-toggle" name="{{.GetFullName}}" type="checkbox"> |
||||
<label for="{{.GetFullName}}"><a class="header">{{.GetFullName}}</a></label> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{{end}} |
||||
</div> |
||||
<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> |
||||
<div class="ui divider"></div> |
||||
<button type="submit" class="ui fluid large green labeled icon button"><i class="icon exchange"></i> Migrate selected repositories...</button> |
||||
</form> |
||||
|
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div id="repo-item" style="display: none !important;"> |
||||
<div class="item"> |
||||
<i class="large github middle aligned icon"></i> |
||||
<div class="content"> |
||||
<div class="ui left toggle checkbox"> |
||||
<input checked id="FULL_REPO_NAME" name="FULL_REPO_NAME" type="checkbox"> |
||||
<label for="FULL_REPO_NAME"><a class="header">FULL_REPO_NAME</a></label> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div class="ui modal" id="add-repos"> |
||||
<div class="header">Add other repositories...</div> |
||||
<div class="content"> |
||||
<div class="ui small icon message"><i class="icon code"></i> |
||||
<div class="content"> |
||||
Please add all repositories you'd like to add in the box below. Write |
||||
each repository in a separate line and split the repository owner and name with a "/". |
||||
</div> |
||||
</div> |
||||
<div class="ui form"> |
||||
<div class="field"> |
||||
<label>Line seperated list of repositories</label> |
||||
<textarea id="repo-textform" placeholder="go-gitea/gitea go-gitea/git"></textarea> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="actions"> |
||||
<div id="add-repos-btn" class="ui approve green button">Add</div> |
||||
<div class="ui cancel button">Cancel</div> |
||||
</div> |
||||
</div> |
||||
{{template "base/footer" .}} |
@ -0,0 +1,14 @@ |
||||
{{template "base/head" .}} |
||||
<div class="ui middle aligned center aligned grid"> |
||||
<div class="column"> |
||||
<h1 class="ui image header"> |
||||
<div class="content"> |
||||
Access denied |
||||
</div> |
||||
</h1> |
||||
<div class="ui error message"> |
||||
{{.ErrTitle}} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{{template "base/footer" .}} |
@ -0,0 +1,14 @@ |
||||
{{template "base/head" .}} |
||||
<div class="ui middle aligned center aligned grid"> |
||||
<div class="column"> |
||||
<h1 class="ui image header"> |
||||
<div class="content"> |
||||
Error 404 - Not found |
||||
</div> |
||||
</h1> |
||||
<div class="ui error message"> |
||||
{{.ErrTitle}} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{{template "base/footer" .}} |
@ -0,0 +1,14 @@ |
||||
{{template "base/head" .}} |
||||
<div class="ui middle aligned center aligned grid"> |
||||
<div class="column"> |
||||
<h1 class="ui image header"> |
||||
<div class="content"> |
||||
Error 500 - Internal Server Error |
||||
</div> |
||||
</h1> |
||||
<div class="ui error message"> |
||||
{{.ErrTitle}} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{{template "base/footer" .}} |
@ -0,0 +1,14 @@ |
||||
{{template "base/head" .}} |
||||
<div class="ui middle aligned center aligned grid"> |
||||
<div class="column"> |
||||
<h1 class="ui image header"> |
||||
<div class="content"> |
||||
Unknown Error |
||||
</div> |
||||
</h1> |
||||
<div class="ui error message"> |
||||
{{.ErrTitle}} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{{template "base/footer" .}} |
Loading…
Reference in new issue