diff --git a/modules/structs/repo.go b/modules/structs/repo.go index 8482a2128d..b1a3781d05 100644 --- a/modules/structs/repo.go +++ b/modules/structs/repo.go @@ -250,6 +250,7 @@ const ( GogsService // 5 gogs service OneDevService // 6 onedev service GitBucketService // 7 gitbucket service + CodebaseService // 8 codebase service ) // Name represents the service type's name @@ -273,6 +274,8 @@ func (gt GitServiceType) Title() string { return "OneDev" case GitBucketService: return "GitBucket" + case CodebaseService: + return "Codebase" case PlainGitService: return "Git" } @@ -330,5 +333,6 @@ var ( GogsService, OneDevService, GitBucketService, + CodebaseService, } ) diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index a2f6389e5c..0a3c0c064c 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -926,6 +926,7 @@ migrate.gitlab.description = Migrate data from gitlab.com or other GitLab instan migrate.gitea.description = Migrate data from gitea.com or other Gitea instances. migrate.gogs.description = Migrate data from notabug.org or other Gogs instances. migrate.onedev.description = Migrate data from code.onedev.io or other OneDev instances. +migrate.codebase.description = Migrate data from codebasehq.com. migrate.gitbucket.description = Migrate data from GitBucket instances. migrate.migrating_git = Migrating Git Data migrate.migrating_topics = Migrating Topics diff --git a/public/img/svg/gitea-codebase.svg b/public/img/svg/gitea-codebase.svg new file mode 100644 index 0000000000..2438230db2 --- /dev/null +++ b/public/img/svg/gitea-codebase.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/services/migrations/codebase.go b/services/migrations/codebase.go new file mode 100644 index 0000000000..8999ee363e --- /dev/null +++ b/services/migrations/codebase.go @@ -0,0 +1,652 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package migrations + +import ( + "context" + "encoding/xml" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "code.gitea.io/gitea/modules/log" + base "code.gitea.io/gitea/modules/migration" + "code.gitea.io/gitea/modules/proxy" + "code.gitea.io/gitea/modules/structs" +) + +var ( + _ base.Downloader = &CodebaseDownloader{} + _ base.DownloaderFactory = &CodebaseDownloaderFactory{} +) + +func init() { + RegisterDownloaderFactory(&CodebaseDownloaderFactory{}) +} + +// CodebaseDownloaderFactory defines a downloader factory +type CodebaseDownloaderFactory struct { +} + +// New returns a downloader related to this factory according MigrateOptions +func (f *CodebaseDownloaderFactory) New(ctx context.Context, opts base.MigrateOptions) (base.Downloader, error) { + u, err := url.Parse(opts.CloneAddr) + if err != nil { + return nil, err + } + u.User = nil + + fields := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(fields) != 2 { + return nil, fmt.Errorf("invalid path: %s", u.Path) + } + project := fields[0] + repoName := strings.TrimSuffix(fields[1], ".git") + + log.Trace("Create Codebase downloader. BaseURL: %v RepoName: %s", u, repoName) + + return NewCodebaseDownloader(ctx, u, project, repoName, opts.AuthUsername, opts.AuthPassword), nil +} + +// GitServiceType returns the type of git service +func (f *CodebaseDownloaderFactory) GitServiceType() structs.GitServiceType { + return structs.CodebaseService +} + +type codebaseUser struct { + ID int64 `json:"id"` + Name string `json:"name"` + Email string `json:"email"` +} + +// CodebaseDownloader implements a Downloader interface to get repository informations +// from Codebase +type CodebaseDownloader struct { + base.NullDownloader + ctx context.Context + client *http.Client + baseURL *url.URL + projectURL *url.URL + project string + repoName string + maxIssueIndex int64 + userMap map[int64]*codebaseUser + commitMap map[string]string +} + +// SetContext set context +func (d *CodebaseDownloader) SetContext(ctx context.Context) { + d.ctx = ctx +} + +// NewCodebaseDownloader creates a new downloader +func NewCodebaseDownloader(ctx context.Context, projectURL *url.URL, project, repoName, username, password string) *CodebaseDownloader { + baseURL, _ := url.Parse("https://api3.codebasehq.com") + + var downloader = &CodebaseDownloader{ + ctx: ctx, + baseURL: baseURL, + projectURL: projectURL, + project: project, + repoName: repoName, + client: &http.Client{ + Transport: &http.Transport{ + Proxy: func(req *http.Request) (*url.URL, error) { + if len(username) > 0 && len(password) > 0 { + req.SetBasicAuth(username, password) + } + return proxy.Proxy()(req) + }, + }, + }, + userMap: make(map[int64]*codebaseUser), + commitMap: make(map[string]string), + } + + return downloader +} + +// FormatCloneURL add authentification into remote URLs +func (d *CodebaseDownloader) FormatCloneURL(opts base.MigrateOptions, remoteAddr string) (string, error) { + return opts.CloneAddr, nil +} + +func (d *CodebaseDownloader) callAPI(endpoint string, parameter map[string]string, result interface{}) error { + u, err := d.baseURL.Parse(endpoint) + if err != nil { + return err + } + + if parameter != nil { + query := u.Query() + for k, v := range parameter { + query.Set(k, v) + } + u.RawQuery = query.Encode() + } + + req, err := http.NewRequestWithContext(d.ctx, "GET", u.String(), nil) + if err != nil { + return err + } + req.Header.Add("Accept", "application/xml") + + resp, err := d.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + return xml.NewDecoder(resp.Body).Decode(&result) +} + +// GetRepoInfo returns repository information +// https://support.codebasehq.com/kb/projects +func (d *CodebaseDownloader) GetRepoInfo() (*base.Repository, error) { + var rawRepository struct { + XMLName xml.Name `xml:"repository"` + Name string `xml:"name"` + Description string `xml:"description"` + Permalink string `xml:"permalink"` + CloneURL string `xml:"clone-url"` + Source string `xml:"source"` + } + + err := d.callAPI( + fmt.Sprintf("/%s/%s", d.project, d.repoName), + nil, + &rawRepository, + ) + if err != nil { + return nil, err + } + + return &base.Repository{ + Name: rawRepository.Name, + Description: rawRepository.Description, + CloneURL: rawRepository.CloneURL, + OriginalURL: d.projectURL.String(), + }, nil +} + +// GetMilestones returns milestones +// https://support.codebasehq.com/kb/tickets-and-milestones/milestones +func (d *CodebaseDownloader) GetMilestones() ([]*base.Milestone, error) { + var rawMilestones struct { + XMLName xml.Name `xml:"ticketing-milestone"` + Type string `xml:"type,attr"` + TicketingMilestone []struct { + Text string `xml:",chardata"` + ID struct { + Value int64 `xml:",chardata"` + Type string `xml:"type,attr"` + } `xml:"id"` + Identifier string `xml:"identifier"` + Name string `xml:"name"` + Deadline struct { + Value string `xml:",chardata"` + Type string `xml:"type,attr"` + } `xml:"deadline"` + Description string `xml:"description"` + Status string `xml:"status"` + } `xml:"ticketing-milestone"` + } + + err := d.callAPI( + fmt.Sprintf("/%s/milestones", d.project), + nil, + &rawMilestones, + ) + if err != nil { + return nil, err + } + + var milestones = make([]*base.Milestone, 0, len(rawMilestones.TicketingMilestone)) + for _, milestone := range rawMilestones.TicketingMilestone { + var deadline *time.Time + if len(milestone.Deadline.Value) > 0 { + if val, err := time.Parse("2006-01-02", milestone.Deadline.Value); err == nil { + deadline = &val + } + } + + closed := deadline + state := "closed" + if milestone.Status == "active" { + closed = nil + state = "" + } + + milestones = append(milestones, &base.Milestone{ + Title: milestone.Name, + Deadline: deadline, + Closed: closed, + State: state, + }) + } + return milestones, nil +} + +// GetLabels returns labels +// https://support.codebasehq.com/kb/tickets-and-milestones/statuses-priorities-and-categories +func (d *CodebaseDownloader) GetLabels() ([]*base.Label, error) { + var rawTypes struct { + XMLName xml.Name `xml:"ticketing-types"` + Type string `xml:"type,attr"` + TicketingType []struct { + ID struct { + Value int64 `xml:",chardata"` + Type string `xml:"type,attr"` + } `xml:"id"` + Name string `xml:"name"` + } `xml:"ticketing-type"` + } + + err := d.callAPI( + fmt.Sprintf("/%s/tickets/types", d.project), + nil, + &rawTypes, + ) + if err != nil { + return nil, err + } + + var labels = make([]*base.Label, 0, len(rawTypes.TicketingType)) + for _, label := range rawTypes.TicketingType { + labels = append(labels, &base.Label{ + Name: label.Name, + Color: "ffffff", + }) + } + return labels, nil +} + +type codebaseIssueContext struct { + foreignID int64 + localID int64 + Comments []*base.Comment +} + +func (c codebaseIssueContext) LocalID() int64 { + return c.localID +} + +func (c codebaseIssueContext) ForeignID() int64 { + return c.foreignID +} + +// GetIssues returns issues, limits are not supported +// https://support.codebasehq.com/kb/tickets-and-milestones +// https://support.codebasehq.com/kb/tickets-and-milestones/updating-tickets +func (d *CodebaseDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, error) { + var rawIssues struct { + XMLName xml.Name `xml:"tickets"` + Type string `xml:"type,attr"` + Ticket []struct { + TicketID struct { + Value int64 `xml:",chardata"` + Type string `xml:"type,attr"` + } `xml:"ticket-id"` + Summary string `xml:"summary"` + TicketType string `xml:"ticket-type"` + ReporterID struct { + Value int64 `xml:",chardata"` + Type string `xml:"type,attr"` + } `xml:"reporter-id"` + Reporter string `xml:"reporter"` + Type struct { + Name string `xml:"name"` + } `xml:"type"` + Status struct { + TreatAsClosed struct { + Value bool `xml:",chardata"` + Type string `xml:"type,attr"` + } `xml:"treat-as-closed"` + } `xml:"status"` + Milestone struct { + Name string `xml:"name"` + } `xml:"milestone"` + UpdatedAt struct { + Value time.Time `xml:",chardata"` + Type string `xml:"type,attr"` + } `xml:"updated-at"` + CreatedAt struct { + Value time.Time `xml:",chardata"` + Type string `xml:"type,attr"` + } `xml:"created-at"` + } `xml:"ticket"` + } + + err := d.callAPI( + fmt.Sprintf("/%s/tickets", d.project), + nil, + &rawIssues, + ) + if err != nil { + return nil, false, err + } + + issues := make([]*base.Issue, 0, len(rawIssues.Ticket)) + for _, issue := range rawIssues.Ticket { + var notes struct { + XMLName xml.Name `xml:"ticket-notes"` + Type string `xml:"type,attr"` + TicketNote []struct { + Content string `xml:"content"` + CreatedAt struct { + Value time.Time `xml:",chardata"` + Type string `xml:"type,attr"` + } `xml:"created-at"` + UpdatedAt struct { + Value time.Time `xml:",chardata"` + Type string `xml:"type,attr"` + } `xml:"updated-at"` + ID struct { + Value int64 `xml:",chardata"` + Type string `xml:"type,attr"` + } `xml:"id"` + UserID struct { + Value int64 `xml:",chardata"` + Type string `xml:"type,attr"` + } `xml:"user-id"` + } `xml:"ticket-note"` + } + err := d.callAPI( + fmt.Sprintf("/%s/tickets/%d/notes", d.project, issue.TicketID.Value), + nil, + ¬es, + ) + if err != nil { + return nil, false, err + } + comments := make([]*base.Comment, 0, len(notes.TicketNote)) + for _, note := range notes.TicketNote { + if len(note.Content) == 0 { + continue + } + poster := d.tryGetUser(note.UserID.Value) + comments = append(comments, &base.Comment{ + IssueIndex: issue.TicketID.Value, + PosterID: poster.ID, + PosterName: poster.Name, + PosterEmail: poster.Email, + Content: note.Content, + Created: note.CreatedAt.Value, + Updated: note.UpdatedAt.Value, + }) + } + if len(comments) == 0 { + comments = append(comments, &base.Comment{}) + } + + state := "open" + if issue.Status.TreatAsClosed.Value { + state = "closed" + } + poster := d.tryGetUser(issue.ReporterID.Value) + issues = append(issues, &base.Issue{ + Title: issue.Summary, + Number: issue.TicketID.Value, + PosterName: poster.Name, + PosterEmail: poster.Email, + Content: comments[0].Content, + Milestone: issue.Milestone.Name, + State: state, + Created: issue.CreatedAt.Value, + Updated: issue.UpdatedAt.Value, + Labels: []*base.Label{ + {Name: issue.Type.Name}}, + Context: codebaseIssueContext{ + foreignID: issue.TicketID.Value, + localID: issue.TicketID.Value, + Comments: comments[1:], + }, + }) + + if d.maxIssueIndex < issue.TicketID.Value { + d.maxIssueIndex = issue.TicketID.Value + } + } + + return issues, true, nil +} + +// GetComments returns comments +func (d *CodebaseDownloader) GetComments(opts base.GetCommentOptions) ([]*base.Comment, bool, error) { + context, ok := opts.Context.(codebaseIssueContext) + if !ok { + return nil, false, fmt.Errorf("unexpected comment context: %+v", opts.Context) + } + + return context.Comments, true, nil +} + +// GetPullRequests returns pull requests +// https://support.codebasehq.com/kb/repositories/merge-requests +func (d *CodebaseDownloader) GetPullRequests(page, perPage int) ([]*base.PullRequest, bool, error) { + var rawMergeRequests struct { + XMLName xml.Name `xml:"merge-requests"` + Type string `xml:"type,attr"` + MergeRequest []struct { + ID struct { + Value int64 `xml:",chardata"` + Type string `xml:"type,attr"` + } `xml:"id"` + } `xml:"merge-request"` + } + + err := d.callAPI( + fmt.Sprintf("/%s/%s/merge_requests", d.project, d.repoName), + map[string]string{ + "query": `"Target Project" is "` + d.repoName + `"`, + "offset": strconv.Itoa((page - 1) * perPage), + "count": strconv.Itoa(perPage), + }, + &rawMergeRequests, + ) + if err != nil { + return nil, false, err + } + + pullRequests := make([]*base.PullRequest, 0, len(rawMergeRequests.MergeRequest)) + for i, mr := range rawMergeRequests.MergeRequest { + var rawMergeRequest struct { + XMLName xml.Name `xml:"merge-request"` + ID struct { + Value int64 `xml:",chardata"` + Type string `xml:"type,attr"` + } `xml:"id"` + SourceRef string `xml:"source-ref"` + TargetRef string `xml:"target-ref"` + Subject string `xml:"subject"` + Status string `xml:"status"` + UserID struct { + Value int64 `xml:",chardata"` + Type string `xml:"type,attr"` + } `xml:"user-id"` + CreatedAt struct { + Value time.Time `xml:",chardata"` + Type string `xml:"type,attr"` + } `xml:"created-at"` + UpdatedAt struct { + Value time.Time `xml:",chardata"` + Type string `xml:"type,attr"` + } `xml:"updated-at"` + Comments struct { + Type string `xml:"type,attr"` + Comment []struct { + Content string `xml:"content"` + UserID struct { + Value int64 `xml:",chardata"` + Type string `xml:"type,attr"` + } `xml:"user-id"` + Action struct { + Value string `xml:",chardata"` + Nil string `xml:"nil,attr"` + } `xml:"action"` + CreatedAt struct { + Value time.Time `xml:",chardata"` + Type string `xml:"type,attr"` + } `xml:"created-at"` + } `xml:"comment"` + } `xml:"comments"` + } + err := d.callAPI( + fmt.Sprintf("/%s/%s/merge_requests/%d", d.project, d.repoName, mr.ID.Value), + nil, + &rawMergeRequest, + ) + if err != nil { + return nil, false, err + } + + number := d.maxIssueIndex + int64(i) + 1 + + state := "open" + merged := false + var closeTime *time.Time + var mergedTime *time.Time + if rawMergeRequest.Status != "new" { + state = "closed" + closeTime = &rawMergeRequest.UpdatedAt.Value + } + + comments := make([]*base.Comment, 0, len(rawMergeRequest.Comments.Comment)) + for _, comment := range rawMergeRequest.Comments.Comment { + if len(comment.Content) == 0 { + if comment.Action.Value == "merging" { + merged = true + mergedTime = &comment.CreatedAt.Value + } + continue + } + poster := d.tryGetUser(comment.UserID.Value) + comments = append(comments, &base.Comment{ + IssueIndex: number, + PosterID: poster.ID, + PosterName: poster.Name, + PosterEmail: poster.Email, + Content: comment.Content, + Created: comment.CreatedAt.Value, + Updated: comment.CreatedAt.Value, + }) + } + if len(comments) == 0 { + comments = append(comments, &base.Comment{}) + } + + poster := d.tryGetUser(rawMergeRequest.UserID.Value) + + pullRequests = append(pullRequests, &base.PullRequest{ + Title: rawMergeRequest.Subject, + Number: number, + PosterName: poster.Name, + PosterEmail: poster.Email, + Content: comments[0].Content, + State: state, + Created: rawMergeRequest.CreatedAt.Value, + Updated: rawMergeRequest.UpdatedAt.Value, + Closed: closeTime, + Merged: merged, + MergedTime: mergedTime, + Head: base.PullRequestBranch{ + Ref: rawMergeRequest.SourceRef, + SHA: d.getHeadCommit(rawMergeRequest.SourceRef), + RepoName: d.repoName, + }, + Base: base.PullRequestBranch{ + Ref: rawMergeRequest.TargetRef, + SHA: d.getHeadCommit(rawMergeRequest.TargetRef), + RepoName: d.repoName, + }, + Context: codebaseIssueContext{ + foreignID: rawMergeRequest.ID.Value, + localID: number, + Comments: comments[1:], + }, + }) + } + + return pullRequests, true, nil +} + +// GetReviews returns pull requests reviews +func (d *CodebaseDownloader) GetReviews(context base.IssueContext) ([]*base.Review, error) { + return []*base.Review{}, nil +} + +// GetTopics return repository topics +func (d *CodebaseDownloader) GetTopics() ([]string, error) { + return []string{}, nil +} + +func (d *CodebaseDownloader) tryGetUser(userID int64) *codebaseUser { + if len(d.userMap) == 0 { + var rawUsers struct { + XMLName xml.Name `xml:"users"` + Type string `xml:"type,attr"` + User []struct { + EmailAddress string `xml:"email-address"` + ID struct { + Value int64 `xml:",chardata"` + Type string `xml:"type,attr"` + } `xml:"id"` + LastName string `xml:"last-name"` + FirstName string `xml:"first-name"` + Username string `xml:"username"` + } `xml:"user"` + } + + err := d.callAPI( + "/users", + nil, + &rawUsers, + ) + if err == nil { + for _, user := range rawUsers.User { + d.userMap[user.ID.Value] = &codebaseUser{ + Name: user.Username, + Email: user.EmailAddress, + } + } + } + } + + user, ok := d.userMap[userID] + if !ok { + user = &codebaseUser{ + Name: fmt.Sprintf("User %d", userID), + } + d.userMap[userID] = user + } + + return user +} + +func (d *CodebaseDownloader) getHeadCommit(ref string) string { + commitRef, ok := d.commitMap[ref] + if !ok { + var rawCommits struct { + XMLName xml.Name `xml:"commits"` + Type string `xml:"type,attr"` + Commit []struct { + Ref string `xml:"ref"` + } `xml:"commit"` + } + err := d.callAPI( + fmt.Sprintf("/%s/%s/commits/%s", d.project, d.repoName, ref), + nil, + &rawCommits, + ) + if err == nil && len(rawCommits.Commit) > 0 { + commitRef = rawCommits.Commit[0].Ref + d.commitMap[ref] = commitRef + } + } + return commitRef +} diff --git a/services/migrations/codebase_test.go b/services/migrations/codebase_test.go new file mode 100644 index 0000000000..ef39b9f146 --- /dev/null +++ b/services/migrations/codebase_test.go @@ -0,0 +1,154 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package migrations + +import ( + "context" + "fmt" + "net/url" + "os" + "testing" + "time" + + base "code.gitea.io/gitea/modules/migration" + + "github.com/stretchr/testify/assert" +) + +func TestCodebaseDownloadRepo(t *testing.T) { + // Skip tests if Codebase token is not found + cloneUser := os.Getenv("CODEBASE_CLONE_USER") + clonePassword := os.Getenv("CODEBASE_CLONE_PASSWORD") + apiUser := os.Getenv("CODEBASE_API_USER") + apiPassword := os.Getenv("CODEBASE_API_TOKEN") + if apiUser == "" || apiPassword == "" { + t.Skip("skipped test because a CODEBASE_ variable was not in the environment") + } + + cloneAddr := "https://gitea-test.codebasehq.com/gitea-test/test.git" + u, _ := url.Parse(cloneAddr) + if cloneUser != "" { + u.User = url.UserPassword(cloneUser, clonePassword) + } + + factory := &CodebaseDownloaderFactory{} + downloader, err := factory.New(context.Background(), base.MigrateOptions{ + CloneAddr: u.String(), + AuthUsername: apiUser, + AuthPassword: apiPassword, + }) + if err != nil { + t.Fatal(fmt.Sprintf("Error creating Codebase downloader: %v", err)) + } + repo, err := downloader.GetRepoInfo() + assert.NoError(t, err) + assertRepositoryEqual(t, &base.Repository{ + Name: "test", + Owner: "", + Description: "Repository Description", + CloneURL: "git@codebasehq.com:gitea-test/gitea-test/test.git", + OriginalURL: cloneAddr, + }, repo) + + milestones, err := downloader.GetMilestones() + assert.NoError(t, err) + assertMilestonesEqual(t, []*base.Milestone{ + { + Title: "Milestone1", + Deadline: timePtr(time.Date(2021, time.September, 16, 0, 0, 0, 0, time.UTC)), + }, + { + Title: "Milestone2", + Deadline: timePtr(time.Date(2021, time.September, 17, 0, 0, 0, 0, time.UTC)), + Closed: timePtr(time.Date(2021, time.September, 17, 0, 0, 0, 0, time.UTC)), + State: "closed", + }, + }, milestones) + + labels, err := downloader.GetLabels() + assert.NoError(t, err) + assert.Len(t, labels, 4) + + issues, isEnd, err := downloader.GetIssues(1, 2) + assert.NoError(t, err) + assert.True(t, isEnd) + assertIssuesEqual(t, []*base.Issue{ + { + Number: 2, + Title: "Open Ticket", + Content: "Open Ticket Message", + PosterName: "gitea-test-43", + PosterEmail: "gitea-codebase@smack.email", + State: "open", + Created: time.Date(2021, time.September, 26, 19, 19, 14, 0, time.UTC), + Updated: time.Date(2021, time.September, 26, 19, 19, 34, 0, time.UTC), + Labels: []*base.Label{ + { + Name: "Feature", + }, + }, + }, + { + Number: 1, + Title: "Closed Ticket", + Content: "Closed Ticket Message", + PosterName: "gitea-test-43", + PosterEmail: "gitea-codebase@smack.email", + State: "closed", + Milestone: "Milestone1", + Created: time.Date(2021, time.September, 26, 19, 18, 33, 0, time.UTC), + Updated: time.Date(2021, time.September, 26, 19, 18, 55, 0, time.UTC), + Labels: []*base.Label{ + { + Name: "Bug", + }, + }, + }, + }, issues) + + comments, _, err := downloader.GetComments(base.GetCommentOptions{ + Context: issues[0].Context, + }) + assert.NoError(t, err) + assertCommentsEqual(t, []*base.Comment{ + { + IssueIndex: 2, + PosterName: "gitea-test-43", + PosterEmail: "gitea-codebase@smack.email", + Created: time.Date(2021, time.September, 26, 19, 19, 34, 0, time.UTC), + Updated: time.Date(2021, time.September, 26, 19, 19, 34, 0, time.UTC), + Content: "open comment", + }, + }, comments) + + prs, _, err := downloader.GetPullRequests(1, 1) + assert.NoError(t, err) + assertPullRequestsEqual(t, []*base.PullRequest{ + { + Number: 3, + Title: "Readme Change", + Content: "Merge Request comment", + PosterName: "gitea-test-43", + PosterEmail: "gitea-codebase@smack.email", + State: "open", + Created: time.Date(2021, time.September, 26, 20, 25, 47, 0, time.UTC), + Updated: time.Date(2021, time.September, 26, 20, 25, 47, 0, time.UTC), + Head: base.PullRequestBranch{ + Ref: "readme-mr", + SHA: "1287f206b888d4d13540e0a8e1c07458f5420059", + RepoName: "test", + }, + Base: base.PullRequestBranch{ + Ref: "master", + SHA: "f32b0a9dfd09a60f616f29158f772cedd89942d2", + RepoName: "test", + }, + }, + }, prs) + + rvs, err := downloader.GetReviews(prs[0].Context) + assert.NoError(t, err) + assert.Empty(t, rvs) +} diff --git a/services/migrations/main_test.go b/services/migrations/main_test.go index 660f6dd845..ddf73df98e 100644 --- a/services/migrations/main_test.go +++ b/services/migrations/main_test.go @@ -32,6 +32,7 @@ func assertTimePtrEqual(t *testing.T, expected, actual *time.Time) { if expected == nil { assert.Nil(t, actual) } else { + assert.NotNil(t, actual) assertTimeEqual(t, *expected, *actual) } } diff --git a/templates/repo/migrate/codebase.tmpl b/templates/repo/migrate/codebase.tmpl new file mode 100644 index 0000000000..10727229c6 --- /dev/null +++ b/templates/repo/migrate/codebase.tmpl @@ -0,0 +1,117 @@ +{{template "base/head" .}} +
+
+
+
+ {{.CsrfTokenHtml}} +

+ {{.i18n.Tr "repo.migrate.migrate" .service.Title}} + +

+
+ {{template "base/alert" .}} +
+ + + + {{.i18n.Tr "repo.migrate.clone_address_desc"}}{{if .ContextUser.CanImportLocal}} {{.i18n.Tr "repo.migrate.clone_local_path"}}{{end}} + +
+ +
+ + +
+ +
+ + +
+ + {{template "repo/migrate/options" .}} + +
+
+ +
+ + +
+
+ + +
+
+
+ +
+ + +
+
+ + +
+
+
+ +
+ +
+ + +
+ +
+ + +
+
+ +
+ {{if .IsForcedPrivate}} + + + {{else}} + + + {{end}} +
+
+
+ + +
+ +
+ + + {{.i18n.Tr "cancel"}} +
+
+
+
+
+
+{{template "base/footer" .}} diff --git a/web_src/svg/gitea-codebase.svg b/web_src/svg/gitea-codebase.svg new file mode 100644 index 0000000000..13c67e7e7f --- /dev/null +++ b/web_src/svg/gitea-codebase.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file