Add migrate from Codebase (#16768)
	
		
	
				
					
				
			This PR adds [Codebase](https://www.codebasehq.com/) as migration source. Supported: - Milestones - Issues - Pull Requests - Comments - Labelstokarchuk/v1.17
							parent
							
								
									957c3fcb59
								
							
						
					
					
						commit
						87be76213a
					
				| 
		 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) | 
				
			||||
} | 
				
			||||
@ -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" .}} | 
				
			||||
| 
		 After Width: | Height: | Size: 5.5 KiB  | 
					Loading…
					
					
				
		Reference in new issue