Add migrate from Codebase (#16768)
This PR adds [Codebase](https://www.codebasehq.com/) as migration source. Supported: - Milestones - Issues - Pull Requests - Comments - Labels
This commit is contained in:
		
							parent
							
								
									957c3fcb59
								
							
						
					
					
						commit
						87be76213a
					
				| 
						 | 
					@ -250,6 +250,7 @@ const (
 | 
				
			||||||
	GogsService                            // 5 gogs service
 | 
						GogsService                            // 5 gogs service
 | 
				
			||||||
	OneDevService                          // 6 onedev service
 | 
						OneDevService                          // 6 onedev service
 | 
				
			||||||
	GitBucketService                       // 7 gitbucket service
 | 
						GitBucketService                       // 7 gitbucket service
 | 
				
			||||||
 | 
						CodebaseService                        // 8 codebase service
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Name represents the service type's name
 | 
					// Name represents the service type's name
 | 
				
			||||||
| 
						 | 
					@ -273,6 +274,8 @@ func (gt GitServiceType) Title() string {
 | 
				
			||||||
		return "OneDev"
 | 
							return "OneDev"
 | 
				
			||||||
	case GitBucketService:
 | 
						case GitBucketService:
 | 
				
			||||||
		return "GitBucket"
 | 
							return "GitBucket"
 | 
				
			||||||
 | 
						case CodebaseService:
 | 
				
			||||||
 | 
							return "Codebase"
 | 
				
			||||||
	case PlainGitService:
 | 
						case PlainGitService:
 | 
				
			||||||
		return "Git"
 | 
							return "Git"
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
| 
						 | 
					@ -330,5 +333,6 @@ var (
 | 
				
			||||||
		GogsService,
 | 
							GogsService,
 | 
				
			||||||
		OneDevService,
 | 
							OneDevService,
 | 
				
			||||||
		GitBucketService,
 | 
							GitBucketService,
 | 
				
			||||||
 | 
							CodebaseService,
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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.gitea.description = Migrate data from gitea.com or other Gitea instances.
 | 
				
			||||||
migrate.gogs.description = Migrate data from notabug.org or other Gogs 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.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.gitbucket.description = Migrate data from GitBucket instances.
 | 
				
			||||||
migrate.migrating_git = Migrating Git Data
 | 
					migrate.migrating_git = Migrating Git Data
 | 
				
			||||||
migrate.migrating_topics = Migrating Topics
 | 
					migrate.migrating_topics = Migrating Topics
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| 
		 After Width: | Height: | Size: 5.4 KiB  | 
| 
						 | 
					@ -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
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -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)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -32,6 +32,7 @@ func assertTimePtrEqual(t *testing.T, expected, actual *time.Time) {
 | 
				
			||||||
	if expected == nil {
 | 
						if expected == nil {
 | 
				
			||||||
		assert.Nil(t, actual)
 | 
							assert.Nil(t, actual)
 | 
				
			||||||
	} else {
 | 
						} else {
 | 
				
			||||||
 | 
							assert.NotNil(t, actual)
 | 
				
			||||||
		assertTimeEqual(t, *expected, *actual)
 | 
							assertTimeEqual(t, *expected, *actual)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,117 @@
 | 
				
			||||||
 | 
					{{template "base/head" .}}
 | 
				
			||||||
 | 
					<div class="page-content repository new migrate">
 | 
				
			||||||
 | 
						<div class="ui middle very relaxed page grid">
 | 
				
			||||||
 | 
							<div class="column">
 | 
				
			||||||
 | 
								<form class="ui form" action="{{.Link}}" method="post">
 | 
				
			||||||
 | 
									{{.CsrfTokenHtml}}
 | 
				
			||||||
 | 
									<h3 class="ui top attached header">
 | 
				
			||||||
 | 
										{{.i18n.Tr "repo.migrate.migrate" .service.Title}}
 | 
				
			||||||
 | 
										<input id="service_type" type="hidden" name="service" value="{{.service}}">
 | 
				
			||||||
 | 
									</h3>
 | 
				
			||||||
 | 
									<div class="ui attached segment">
 | 
				
			||||||
 | 
										{{template "base/alert" .}}
 | 
				
			||||||
 | 
										<div class="inline required field {{if .Err_CloneAddr}}error{{end}}">
 | 
				
			||||||
 | 
											<label for="clone_addr">{{.i18n.Tr "repo.migrate.clone_address"}}</label>
 | 
				
			||||||
 | 
											<input id="clone_addr" name="clone_addr" value="{{.clone_addr}}" autofocus required>
 | 
				
			||||||
 | 
											<span class="help">
 | 
				
			||||||
 | 
											{{.i18n.Tr "repo.migrate.clone_address_desc"}}{{if .ContextUser.CanImportLocal}} {{.i18n.Tr "repo.migrate.clone_local_path"}}{{end}}
 | 
				
			||||||
 | 
											</span>
 | 
				
			||||||
 | 
										</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
										<div class="inline field {{if .Err_Auth}}error{{end}}">
 | 
				
			||||||
 | 
											<label for="auth_username">{{.i18n.Tr "username"}}</label>
 | 
				
			||||||
 | 
											<input id="auth_username" name="auth_username" value="{{.auth_username}}" {{if not .auth_username}}data-need-clear="true"{{end}}>
 | 
				
			||||||
 | 
										</div>
 | 
				
			||||||
 | 
										<input class="fake" type="password">
 | 
				
			||||||
 | 
										<div class="inline field {{if .Err_Auth}}error{{end}}">
 | 
				
			||||||
 | 
											<label for="auth_password">{{.i18n.Tr "password"}}</label>
 | 
				
			||||||
 | 
											<input id="auth_password" name="auth_password" type="password" value="{{.auth_password}}">
 | 
				
			||||||
 | 
										</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
										{{template "repo/migrate/options" .}}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
										<div id="migrate_items">
 | 
				
			||||||
 | 
											<div class="inline field">
 | 
				
			||||||
 | 
												<label>{{.i18n.Tr "repo.migrate_items"}}</label>
 | 
				
			||||||
 | 
												<div class="ui checkbox">
 | 
				
			||||||
 | 
													<input name="milestones" type="checkbox" {{if .milestones}}checked{{end}}>
 | 
				
			||||||
 | 
													<label>{{.i18n.Tr "repo.migrate_items_milestones" | Safe}}</label>
 | 
				
			||||||
 | 
												</div>
 | 
				
			||||||
 | 
												<div class="ui checkbox">
 | 
				
			||||||
 | 
													<input name="labels" type="checkbox" {{if .labels}}checked{{end}}>
 | 
				
			||||||
 | 
													<label>{{.i18n.Tr "repo.migrate_items_labels" | Safe}}</label>
 | 
				
			||||||
 | 
												</div>
 | 
				
			||||||
 | 
											</div>
 | 
				
			||||||
 | 
											<div class="inline field">
 | 
				
			||||||
 | 
												<label></label>
 | 
				
			||||||
 | 
												<div class="ui checkbox">
 | 
				
			||||||
 | 
													<input name="issues" type="checkbox" {{if .issues}}checked{{end}}>
 | 
				
			||||||
 | 
													<label>{{.i18n.Tr "repo.migrate_items_issues" | Safe}}</label>
 | 
				
			||||||
 | 
												</div>
 | 
				
			||||||
 | 
												<div class="ui checkbox">
 | 
				
			||||||
 | 
													<input name="pull_requests" type="checkbox" {{if .pull_requests}}checked{{end}}>
 | 
				
			||||||
 | 
													<label>{{.i18n.Tr "repo.migrate_items_merge_requests" | Safe}}</label>
 | 
				
			||||||
 | 
												</div>
 | 
				
			||||||
 | 
											</div>
 | 
				
			||||||
 | 
										</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
										<div class="ui divider"></div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
										<div class="inline required field {{if .Err_Owner}}error{{end}}">
 | 
				
			||||||
 | 
											<label>{{.i18n.Tr "repo.owner"}}</label>
 | 
				
			||||||
 | 
											<div class="ui selection owner dropdown">
 | 
				
			||||||
 | 
												<input type="hidden" id="uid" name="uid" value="{{.ContextUser.ID}}" required>
 | 
				
			||||||
 | 
												<span class="text truncated-item-container" title="{{.ContextUser.Name}}">
 | 
				
			||||||
 | 
													{{avatar .ContextUser 28 "mini"}}
 | 
				
			||||||
 | 
													<span class="truncated-item-name">{{.ContextUser.ShortName 40}}</span>
 | 
				
			||||||
 | 
												</span>
 | 
				
			||||||
 | 
												{{svg "octicon-triangle-down" 14 "dropdown icon"}}
 | 
				
			||||||
 | 
												<div class="menu" title="{{.SignedUser.Name}}">
 | 
				
			||||||
 | 
													<div class="item truncated-item-container" data-value="{{.SignedUser.ID}}">
 | 
				
			||||||
 | 
														{{avatar .SignedUser 28 "mini"}}
 | 
				
			||||||
 | 
														<span class="truncated-item-name">{{.SignedUser.ShortName 40}}</span>
 | 
				
			||||||
 | 
													</div>
 | 
				
			||||||
 | 
													{{range .Orgs}}
 | 
				
			||||||
 | 
														<div class="item truncated-item-container" data-value="{{.ID}}" title="{{.Name}}">
 | 
				
			||||||
 | 
															{{avatar . 28 "mini"}}
 | 
				
			||||||
 | 
															<span class="truncated-item-name">{{.ShortName 40}}</span>
 | 
				
			||||||
 | 
														</div>
 | 
				
			||||||
 | 
													{{end}}
 | 
				
			||||||
 | 
												</div>
 | 
				
			||||||
 | 
											</div>
 | 
				
			||||||
 | 
										</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
										<div class="inline required field {{if .Err_RepoName}}error{{end}}">
 | 
				
			||||||
 | 
											<label for="repo_name">{{.i18n.Tr "repo.repo_name"}}</label>
 | 
				
			||||||
 | 
											<input id="repo_name" name="repo_name" value="{{.repo_name}}" required>
 | 
				
			||||||
 | 
										</div>
 | 
				
			||||||
 | 
										<div class="inline field">
 | 
				
			||||||
 | 
											<label>{{.i18n.Tr "repo.visibility"}}</label>
 | 
				
			||||||
 | 
											<div class="ui checkbox">
 | 
				
			||||||
 | 
												{{if .IsForcedPrivate}}
 | 
				
			||||||
 | 
													<input name="private" type="checkbox" checked readonly>
 | 
				
			||||||
 | 
													<label>{{.i18n.Tr "repo.visibility_helper_forced" | Safe}}</label>
 | 
				
			||||||
 | 
												{{else}}
 | 
				
			||||||
 | 
													<input name="private" type="checkbox" {{if .private}}checked{{end}}>
 | 
				
			||||||
 | 
													<label>{{.i18n.Tr "repo.visibility_helper" | Safe}}</label>
 | 
				
			||||||
 | 
												{{end}}
 | 
				
			||||||
 | 
											</div>
 | 
				
			||||||
 | 
										</div>
 | 
				
			||||||
 | 
										<div class="inline field {{if .Err_Description}}error{{end}}">
 | 
				
			||||||
 | 
											<label for="description">{{.i18n.Tr "repo.repo_desc"}}</label>
 | 
				
			||||||
 | 
											<textarea id="description" name="description">{{.description}}</textarea>
 | 
				
			||||||
 | 
										</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
										<div class="inline field">
 | 
				
			||||||
 | 
											<label></label>
 | 
				
			||||||
 | 
											<button class="ui green button">
 | 
				
			||||||
 | 
												{{.i18n.Tr "repo.migrate_repo"}}
 | 
				
			||||||
 | 
											</button>
 | 
				
			||||||
 | 
											<a class="ui button" href="{{AppSubUrl}}/">{{.i18n.Tr "cancel"}}</a>
 | 
				
			||||||
 | 
										</div>
 | 
				
			||||||
 | 
									</div>
 | 
				
			||||||
 | 
								</form>
 | 
				
			||||||
 | 
							</div>
 | 
				
			||||||
 | 
						</div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					{{template "base/footer" .}}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,13 @@
 | 
				
			||||||
 | 
					<?xml version="1.0" encoding="UTF-8"?>
 | 
				
			||||||
 | 
					<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2516.000000 543.000000">
 | 
				
			||||||
 | 
					<g transform="translate(0.000000,543.000000) scale(0.100000,-0.100000)" fill="#000000" stroke="none">
 | 
				
			||||||
 | 
					<path d="M7600 4219 l0 -800 -62 72 c-114 132 -302 257 -483 322 -134 47 -226 61 -410 61 -245 0 -390 -33 -600 -133 -288 -138 -549 -431 -674 -758 l-21 -53 330 -334 c274 -277 330 -338 330 -361 0 -24 -57 -85 -341 -371 l-342 -343 28 -78 c173 -492 574 -839 1060 -919 122 -19 361 -15 475 10 242 53 448 165 622 338 l88 87 0 -184 0 -185 505 0 505 0 0 2215 0 2215 -505 0 -505 0 0 -801z m-432 -1269 c145 -35 274 -104 380 -203 l52 -50 0 -498 0 -498 -51 -50 c-68 -67 -142 -115 -247 -161 -115 -49 -210 -70 -326 -70 -126 0 -182 13 -296 67 -206 99 -351 308 -391 564 -16 105 -6 305 21 399 28 98 88 211 154 292 83 100 174 160 311 204 96 31 274 33 393 4z"/>
 | 
				
			||||||
 | 
					<path d="M12890 2805 l0 -2215 175 0 175 0 0 251 0 251 42 -54 c209 -266 544 -465 867 -513 442 -67 875 72 1171 375 150 153 290 387 360 599 136 415 134 1002 -6 1402 -175 504 -544 846 -1024 950 -147 32 -416 32 -567 0 -314 -66 -581 -233 -784 -491 l-64 -82 -3 871 -2 871 -170 0 -170 0 0 -2215z m1550 754 c122 -15 193 -35 296 -84 178 -83 312 -198 419 -360 147 -220 222 -457 247 -775 25 -326 -32 -659 -154 -905 -153 -307 -373 -495 -676 -577 -96 -26 -330 -35 -456 -19 -259 35 -537 173 -726 361 -48 47 -103 110 -123 140 l-37 55 0 791 0 791 49 69 c94 134 258 272 441 372 226 123 475 172 720 141z"/>
 | 
				
			||||||
 | 
					<path d="M1675 3874 c-292 -30 -497 -87 -702 -194 -434 -226 -728 -614 -829 -1094 -35 -164 -44 -482 -20 -657 50 -357 186 -643 420 -887 213 -221 434 -358 730 -452 282 -89 676 -105 984 -40 198 42 416 131 576 238 71 47 264 230 304 288 l22 32 -328 309 -327 308 -95 -95 c-75 -76 -111 -104 -171 -133 -112 -55 -211 -77 -344 -77 -359 0 -637 213 -726 556 -26 101 -29 325 -6 425 62 261 230 451 476 537 68 23 94 27 236 30 145 3 168 1 241 -21 123 -35 223 -98 312 -193 l76 -81 327 305 c268 249 326 308 319 322 -18 33 -151 173 -218 229 -137 115 -302 202 -501 265 -193 61 -301 78 -521 81 -107 2 -213 1 -235 -1z"/>
 | 
				
			||||||
 | 
					<path d="M10427 3869 c-334 -32 -684 -182 -911 -390 -226 -208 -386 -446 -480 -718 -70 -200 -80 -275 -80 -556 0 -267 8 -343 59 -526 101 -358 321 -657 645 -874 318 -214 703 -308 1156 -285 212 11 374 37 572 92 263 72 428 153 621 307 l25 19 -223 335 -223 335 -81 -55 c-103 -71 -194 -113 -327 -153 -152 -45 -255 -60 -405 -60 -164 0 -279 26 -423 96 -80 40 -111 62 -177 128 -82 82 -146 187 -161 264 l-6 32 1136 0 1136 0 0 210 c0 313 -30 523 -106 742 -86 245 -211 444 -396 624 -259 254 -565 396 -937 433 -118 12 -285 12 -414 0z m527 -843 c205 -57 361 -211 410 -406 l13 -55 -650 -3 c-358 -1 -652 0 -655 3 -10 10 30 131 63 190 87 156 224 251 413 285 74 14 338 4 406 -14z"/>
 | 
				
			||||||
 | 
					<path d="M17330 3870 c-254 -31 -500 -123 -715 -266 -82 -55 -219 -168 -277 -230 l-27 -29 85 -110 c47 -61 89 -114 93 -118 5 -5 48 32 97 80 160 159 371 280 594 338 90 24 131 28 282 32 237 7 340 -12 504 -94 156 -77 264 -181 330 -318 64 -135 67 -151 71 -572 3 -211 2 -383 -2 -383 -4 0 -42 33 -84 73 -182 173 -433 299 -680 343 -131 23 -351 34 -460 23 -376 -38 -729 -264 -879 -564 -39 -78 -77 -200 -92 -296 -19 -116 -8 -383 19 -482 87 -326 330 -583 671 -710 154 -57 232 -70 425 -70 405 -1 727 120 999 375 l86 80 0 -191 0 -191 170 0 170 0 0 1183 c0 978 -3 1198 -15 1277 -42 282 -163 478 -382 622 -210 139 -420 197 -733 203 -96 2 -209 0 -250 -5z m260 -1510 c299 -37 562 -171 731 -373 l49 -59 0 -355 0 -355 -102 -102 c-210 -209 -446 -308 -774 -325 -240 -13 -375 9 -528 85 -101 51 -153 89 -237 175 -87 88 -156 214 -185 334 -24 99 -24 300 -1 385 82 307 349 537 687 593 52 9 279 7 360 -3z"/>
 | 
				
			||||||
 | 
					<path d="M23345 3873 c-382 -35 -739 -228 -988 -535 -327 -405 -450 -883 -372 -1447 53 -374 201 -679 454 -932 120 -119 234 -204 370 -273 133 -67 216 -97 361 -129 508 -113 1004 -18 1402 266 102 73 240 195 236 209 -8 22 -164 217 -175 217 -6 1 -18 -7 -25 -17 -26 -36 -144 -136 -223 -187 -161 -106 -342 -178 -529 -212 -131 -24 -422 -24 -536 1 -228 48 -414 145 -577 299 -211 201 -358 497 -398 804 -8 63 -15 123 -15 134 0 19 18 19 1340 19 l1340 0 0 118 c0 416 -124 816 -346 1114 -77 103 -237 258 -331 321 -144 96 -315 167 -483 201 -144 29 -367 42 -505 29z m351 -304 c171 -24 345 -93 476 -191 186 -138 348 -378 428 -637 31 -99 60 -263 60 -338 l0 -43 -1166 0 -1166 0 7 63 c49 452 288 841 636 1036 78 43 206 89 297 105 94 17 323 20 428 5z"/>
 | 
				
			||||||
 | 
					<path d="M20115 3855 c-194 -30 -394 -107 -515 -197 -256 -193 -369 -419 -358 -717 4 -96 10 -131 35 -202 55 -159 160 -283 336 -396 145 -94 259 -133 737 -252 377 -94 468 -125 626 -211 196 -108 283 -240 284 -430 0 -299 -229 -540 -585 -615 -121 -26 -377 -31 -495 -11 -134 23 -248 60 -375 120 -138 66 -235 134 -348 245 l-88 86 -22 -26 c-12 -15 -59 -72 -104 -127 l-83 -100 99 -95 c214 -205 463 -330 779 -389 100 -19 153 -22 357 -22 258 0 331 8 505 61 429 129 690 463 690 885 0 227 -81 422 -238 570 -202 190 -337 247 -962 404 -284 71 -388 107 -544 188 -184 95 -256 190 -264 350 -8 159 38 279 153 392 84 83 207 150 340 185 110 30 395 38 540 15 275 -42 476 -142 639 -318 66 -71 78 -80 91 -67 7 8 52 61 100 119 l86 105 -73 69 c-228 215 -475 333 -799 380 -128 19 -427 20 -544 1z"/>
 | 
				
			||||||
 | 
					<path d="M3736 3569 c-48 -50 -353 -363 -679 -695 -511 -523 -592 -609 -592 -635 0 -25 91 -122 683 -724 l683 -695 115 120 c63 65 124 130 135 144 11 14 26 26 32 26 7 0 77 -66 158 -146 l146 -146 689 696 c514 519 690 702 692 722 3 23 -27 58 -220 253 -123 124 -434 439 -691 700 l-466 474 -155 -147 -154 -146 -138 145 c-75 80 -141 145 -145 145 -3 0 -46 -41 -93 -91z m728 -1008 l315 -324 -316 -324 c-174 -178 -320 -322 -324 -321 -4 2 -156 147 -337 323 l-331 320 327 328 c180 180 332 326 339 325 7 -2 154 -149 327 -327z" fill="#e22c2c"/>
 | 
				
			||||||
 | 
					</g>
 | 
				
			||||||
 | 
					</svg>
 | 
				
			||||||
| 
		 After Width: | Height: | Size: 5.5 KiB  | 
		Loading…
	
		Reference in New Issue